diff --git a/devenv/dev-dashboards/panel-graph/graph-ng.json b/devenv/dev-dashboards/panel-graph/graph-ng.json new file mode 100644 index 00000000000..b0f87c0222b --- /dev/null +++ b/devenv/dev-dashboards/panel-graph/graph-ng.json @@ -0,0 +1,2253 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": 90, + "links": [], + "panels": [ + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "axis": { + "grid": true, + "label": "", + "side": 3, + "width": 60 + }, + "bars": { + "show": false + }, + "fill": { + "alpha": 0.1 + }, + "line": { + "show": true, + "width": 1 + }, + "nullValues": "null", + "points": { + "radius": 4, + "show": false + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 40, + "options": { + "graph": { + "realTimeUpdates": false + }, + "legend": { + "asTable": false, + "isVisible": true, + "placement": "bottom" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.3.0-pre", + "targets": [ + { + "refId": "A", + "scenarioId": "random_walk" + }, + { + "refId": "B", + "scenarioId": "random_walk" + }, + { + "refId": "C", + "scenarioId": "random_walk" + }, + { + "refId": "D", + "scenarioId": "random_walk" + }, + { + "refId": "E", + "scenarioId": "random_walk" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "React NG graph", + "type": "graph3" + }, + { + "aliasColors": { + "B-series": "purple" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "gdev-testdata", + "fieldConfig": { + "defaults": { + "custom": { + "align": null, + "axis": { + "grid": true, + "label": "", + "side": 3, + "width": 80 + }, + "bars": { + "show": false + }, + "fill": { + "alpha": 0 + }, + "line": { + "show": false, + "width": 1 + }, + "nullValues": "null", + "points": { + "radius": 2, + "show": true + } + }, + "decimals": 2, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "celsius" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "B-series" + }, + "properties": [ + { + "id": "unit", + "value": "fahrenheit" + }, + { + "id": "custom.axis.side", + "value": 1 + }, + { + "id": "custom.line.show", + "value": true + }, + { + "id": "custom.axis.label", + "value": "Temperature (F)" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "C-series" + }, + "properties": [ + { + "id": "unit", + "value": "kelvin" + }, + { + "id": "custom.axis.label", + "value": "Temperature (K)" + }, + { + "id": "custom.axis.side", + "value": 1 + }, + { + "id": "custom.axis.grid", + "value": false + } + ] + } + ] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 11, + "w": 12, + "x": 12, + "y": 0 + }, + "hiddenSeries": false, + "id": 22, + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.3.0-pre", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "C-series", + "yaxis": 2 + }, + { + "alias": "B-series", + "yaxis": 2 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "labels": "", + "refId": "A", + "scenarioId": "random_walk" + }, + { + "refId": "B", + "scenarioId": "random_walk" + }, + { + "refId": "C", + "scenarioId": "random_walk" + }, + { + "refId": "D", + "scenarioId": "random_walk" + }, + { + "refId": "E", + "scenarioId": "random_walk" + }, + { + "refId": "F", + "scenarioId": "random_walk" + }, + { + "refId": "G", + "scenarioId": "random_walk" + }, + { + "refId": "H", + "scenarioId": "random_walk" + }, + { + "refId": "I", + "scenarioId": "random_walk" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Angular graph", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "transformations": [], + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "celsius", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "kelvin", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": true, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 11 + }, + "id": 34, + "panels": [ + { + "datasource": "gdev-testdata", + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "align": null, + "axis": { + "grid": true, + "label": "", + "side": 3, + "width": 40 + }, + "bars": { + "show": false + }, + "fill": { + "alpha": 0.2 + }, + "line": { + "show": true, + "width": 1 + }, + "nullValues": "null", + "points": { + "radius": 5, + "show": false + } + }, + "decimals": 2, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "celsius" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "B-series" + }, + "properties": [ + { + "id": "unit", + "value": "fahrenheit" + }, + { + "id": "custom.axis.side", + "value": 1 + }, + { + "id": "custom.line.show", + "value": true + }, + { + "id": "custom.axis.width", + "value": 40 + } + ] + } + ] + }, + "gridPos": { + "h": 11, + "w": 8, + "x": 0, + "y": 12 + }, + "id": 31, + "options": { + "graph": { + "realTimeUpdates": false + }, + "legend": { + "asTable": true, + "isVisible": true, + "placement": "bottom" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.2.0-pre", + "targets": [ + { + "hide": false, + "refId": "A", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "B", + "scenarioId": "random_walk" + }, + { + "refId": "C", + "scenarioId": "random_walk" + }, + { + "labels": "", + "refId": "D", + "scenarioId": "random_walk" + }, + { + "refId": "E", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "F", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "G", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "H", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "I", + "scenarioId": "random_walk" + }, + { + "refId": "J", + "scenarioId": "random_walk" + }, + { + "refId": "K", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "L", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "M", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "N", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "O", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "P", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "Q", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "R", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "S", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "T", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "U", + "scenarioId": "random_walk" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Table", + "transformations": [], + "type": "graph3" + }, + { + "datasource": "gdev-testdata", + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "align": null, + "axis": { + "grid": true, + "label": "", + "side": 3, + "width": 40 + }, + "bars": { + "show": false + }, + "fill": { + "alpha": 0.2 + }, + "line": { + "show": true, + "width": 1 + }, + "nullValues": "null", + "points": { + "radius": 5, + "show": false + } + }, + "decimals": 2, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "celsius" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "B-series" + }, + "properties": [ + { + "id": "unit", + "value": "fahrenheit" + }, + { + "id": "custom.axis.side", + "value": 1 + }, + { + "id": "custom.line.show", + "value": true + }, + { + "id": "custom.axis.width", + "value": 40 + } + ] + } + ] + }, + "gridPos": { + "h": 11, + "w": 8, + "x": 8, + "y": 12 + }, + "id": 32, + "options": { + "graph": { + "realTimeUpdates": false + }, + "legend": { + "asTable": false, + "isVisible": true, + "placement": "bottom" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.2.0-pre", + "targets": [ + { + "hide": false, + "refId": "A", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "B", + "scenarioId": "random_walk" + }, + { + "refId": "C", + "scenarioId": "random_walk" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "List", + "transformations": [], + "type": "graph3" + }, + { + "datasource": "gdev-testdata", + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "align": null, + "axis": { + "grid": true, + "label": "", + "side": 3, + "width": 40 + }, + "bars": { + "show": false + }, + "fill": { + "alpha": 0.2 + }, + "line": { + "show": true, + "width": 1 + }, + "nullValues": "null", + "points": { + "radius": 5, + "show": false + } + }, + "decimals": 2, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "celsius" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "B-series" + }, + "properties": [ + { + "id": "unit", + "value": "fahrenheit" + }, + { + "id": "custom.axis.side", + "value": 1 + }, + { + "id": "custom.line.show", + "value": true + }, + { + "id": "custom.axis.width", + "value": 40 + } + ] + } + ] + }, + "gridPos": { + "h": 11, + "w": 8, + "x": 16, + "y": 12 + }, + "id": 35, + "options": { + "graph": { + "realTimeUpdates": false + }, + "legend": { + "asTable": false, + "isVisible": false, + "placement": "bottom" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.2.0-pre", + "targets": [ + { + "hide": false, + "refId": "A", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "B", + "scenarioId": "random_walk" + }, + { + "refId": "C", + "scenarioId": "random_walk" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "No legend", + "transformations": [], + "type": "graph3" + } + ], + "title": "Legend rendering", + "type": "row" + }, + { + "collapsed": true, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 12 + }, + "id": 24, + "panels": [ + { + "datasource": "gdev-testdata", + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "align": null, + "axis": { + "grid": true, + "label": "", + "side": 3, + "width": 40 + }, + "bars": { + "show": false + }, + "fill": { + "alpha": 0.2 + }, + "line": { + "show": false, + "width": 1 + }, + "nullValues": "null", + "points": { + "radius": 5, + "show": false + } + }, + "decimals": 2, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "celsius" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "B-series" + }, + "properties": [ + { + "id": "unit", + "value": "fahrenheit" + }, + { + "id": "custom.axis.side", + "value": 1 + }, + { + "id": "custom.line.show", + "value": true + }, + { + "id": "custom.axis.width", + "value": 40 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "C-series" + }, + "properties": [ + { + "id": "unit", + "value": "kelvin" + }, + { + "id": "custom.axis.side", + "value": 1 + }, + { + "id": "custom.axis.grid", + "value": false + }, + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + }, + { + "id": "custom.axis.width", + "value": 40 + } + ] + } + ] + }, + "gridPos": { + "h": 11, + "w": 6, + "x": 0, + "y": 24 + }, + "id": 25, + "options": { + "graph": { + "realTimeUpdates": false + }, + "legend": { + "asTable": false, + "isVisible": true, + "placement": "bottom" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.2.0-pre", + "targets": [ + { + "hide": false, + "refId": "A", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "B", + "scenarioId": "random_walk" + }, + { + "refId": "C", + "scenarioId": "random_walk" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Bottom", + "transformations": [], + "type": "graph3" + }, + { + "datasource": "gdev-testdata", + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "align": null, + "axis": { + "grid": true, + "label": "", + "side": 3, + "width": 40 + }, + "bars": { + "show": false + }, + "fill": { + "alpha": 0.2 + }, + "line": { + "show": false, + "width": 1 + }, + "nullValues": "null", + "points": { + "radius": 5, + "show": false + } + }, + "decimals": 2, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "celsius" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "B-series" + }, + "properties": [ + { + "id": "unit", + "value": "fahrenheit" + }, + { + "id": "custom.axis.side", + "value": 1 + }, + { + "id": "custom.line.show", + "value": true + }, + { + "id": "custom.axis.width", + "value": 40 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "C-series" + }, + "properties": [ + { + "id": "unit", + "value": "kelvin" + }, + { + "id": "custom.axis.side", + "value": 1 + }, + { + "id": "custom.axis.grid", + "value": false + }, + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + }, + { + "id": "custom.axis.width", + "value": 40 + } + ] + } + ] + }, + "gridPos": { + "h": 11, + "w": 6, + "x": 6, + "y": 24 + }, + "id": 26, + "options": { + "graph": { + "realTimeUpdates": false + }, + "legend": { + "asTable": false, + "isVisible": true, + "placement": "top" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.2.0-pre", + "targets": [ + { + "hide": false, + "refId": "A", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "B", + "scenarioId": "random_walk" + }, + { + "refId": "C", + "scenarioId": "random_walk" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Top", + "transformations": [], + "type": "graph3" + }, + { + "datasource": "gdev-testdata", + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "align": null, + "axis": { + "grid": true, + "label": "", + "side": 3, + "width": 40 + }, + "bars": { + "show": false + }, + "fill": { + "alpha": 0.2 + }, + "line": { + "show": false, + "width": 1 + }, + "nullValues": "null", + "points": { + "radius": 5, + "show": false + } + }, + "decimals": 2, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "celsius" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "B-series" + }, + "properties": [ + { + "id": "unit", + "value": "fahrenheit" + }, + { + "id": "custom.axis.side", + "value": 1 + }, + { + "id": "custom.line.show", + "value": true + }, + { + "id": "custom.axis.width", + "value": 40 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "C-series" + }, + "properties": [ + { + "id": "unit", + "value": "kelvin" + }, + { + "id": "custom.axis.side", + "value": 1 + }, + { + "id": "custom.axis.grid", + "value": false + }, + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + }, + { + "id": "custom.axis.width", + "value": 40 + } + ] + } + ] + }, + "gridPos": { + "h": 11, + "w": 6, + "x": 12, + "y": 24 + }, + "id": 28, + "options": { + "graph": { + "realTimeUpdates": false + }, + "legend": { + "asTable": false, + "isVisible": true, + "placement": "left" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.2.0-pre", + "targets": [ + { + "hide": false, + "refId": "A", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "B", + "scenarioId": "random_walk" + }, + { + "refId": "C", + "scenarioId": "random_walk" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Left", + "transformations": [], + "type": "graph3" + }, + { + "datasource": "gdev-testdata", + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "align": null, + "axis": { + "grid": true, + "label": "", + "side": 3, + "width": 40 + }, + "bars": { + "show": false + }, + "fill": { + "alpha": 0.2 + }, + "line": { + "show": false, + "width": 1 + }, + "nullValues": "null", + "points": { + "radius": 5, + "show": false + } + }, + "decimals": 2, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "celsius" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "B-series" + }, + "properties": [ + { + "id": "unit", + "value": "fahrenheit" + }, + { + "id": "custom.axis.side", + "value": 1 + }, + { + "id": "custom.line.show", + "value": true + }, + { + "id": "custom.axis.width", + "value": 40 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "C-series" + }, + "properties": [ + { + "id": "unit", + "value": "kelvin" + }, + { + "id": "custom.axis.side", + "value": 1 + }, + { + "id": "custom.axis.grid", + "value": false + }, + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + }, + { + "id": "custom.axis.width", + "value": 40 + } + ] + } + ] + }, + "gridPos": { + "h": 11, + "w": 6, + "x": 18, + "y": 24 + }, + "id": 27, + "options": { + "graph": { + "realTimeUpdates": false + }, + "legend": { + "asTable": false, + "isVisible": true, + "placement": "right" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.2.0-pre", + "targets": [ + { + "hide": false, + "refId": "A", + "scenarioId": "random_walk" + }, + { + "hide": false, + "refId": "B", + "scenarioId": "random_walk" + }, + { + "refId": "C", + "scenarioId": "random_walk" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Right", + "transformations": [], + "type": "graph3" + } + ], + "title": "Legend placement", + "type": "row" + }, + { + "collapsed": true, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 13 + }, + "id": 17, + "panels": [ + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "align": null, + "axis": { + "grid": true, + "label": "", + "side": 3, + "width": 60 + }, + "bars": { + "show": false + }, + "fill": { + "alpha": 0 + }, + "line": { + "show": true, + "width": 1 + }, + "nullValues": "null", + "points": { + "radius": 5, + "show": false + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 14 + }, + "id": 19, + "options": { + "graph": { + "realTimeUpdates": false + }, + "legend": { + "asTable": false, + "isVisible": true, + "placement": "under" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.2.0-pre", + "targets": [ + { + "refId": "A", + "scenarioId": "random_walk" + }, + { + "refId": "B", + "scenarioId": "random_walk" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Single mode", + "type": "graph3" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "align": null, + "axis": { + "grid": true, + "label": "", + "side": 3, + "width": 60 + }, + "bars": { + "show": false + }, + "fill": { + "alpha": 0 + }, + "line": { + "show": true, + "width": 1 + }, + "nullValues": "null", + "points": { + "radius": 5, + "show": false + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 14 + }, + "id": 20, + "options": { + "graph": { + "realTimeUpdates": false + }, + "legend": { + "asTable": false, + "isVisible": true, + "placement": "under" + }, + "tooltipOptions": { + "mode": "multi" + } + }, + "pluginVersion": "7.2.0-pre", + "targets": [ + { + "refId": "A", + "scenarioId": "random_walk" + }, + { + "refId": "B", + "scenarioId": "random_walk" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Multi mode", + "type": "graph3" + }, + { + "datasource": null, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "axis": { + "grid": true, + "label": "", + "side": 3, + "width": 60 + }, + "bars": { + "show": false + }, + "fill": { + "alpha": 0 + }, + "line": { + "show": true, + "width": 1 + }, + "nullValues": "null", + "points": { + "radius": 5, + "show": false + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 14 + }, + "id": 21, + "options": { + "graph": { + "realTimeUpdates": false + }, + "legend": { + "asTable": false, + "isVisible": true, + "placement": "under" + }, + "tooltipOptions": { + "mode": "none" + } + }, + "pluginVersion": "7.2.0-pre", + "targets": [ + { + "refId": "A", + "scenarioId": "random_walk" + }, + { + "refId": "B", + "scenarioId": "random_walk" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "No tooltip", + "type": "graph3" + } + ], + "title": "Tooltip", + "type": "row" + }, + { + "collapsed": true, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 14 + }, + "id": 11, + "panels": [ + { + "datasource": "gdev-testdata", + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "axis": { + "grid": true, + "label": "Temperature", + "side": 3, + "width": 60 + }, + "bars": { + "show": false + }, + "fill": { + "alpha": 0 + }, + "line": { + "show": true, + "width": 1 + }, + "nullValues": "null", + "points": { + "radius": 8, + "show": true + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "celsius" + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 8, + "x": 0, + "y": 15 + }, + "id": 8, + "options": { + "graph": { + "realTimeUpdates": false + }, + "legend": { + "asTable": false, + "isVisible": true, + "placement": "under" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.2.0-pre", + "targets": [ + { + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + }, + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "10,25,null,null,50,10" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "As null", + "transformations": [], + "type": "graph3" + }, + { + "datasource": "gdev-testdata", + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "axis": { + "grid": true, + "label": "", + "side": 3, + "width": 60 + }, + "bars": { + "show": false + }, + "custom": { + "axis": { + "grid": true, + "label": "Temperature", + "side": 3 + }, + "bars": { + "show": false + }, + "line": { + "show": true + }, + "points": { + "radius": 8, + "show": true + } + }, + "fill": { + "alpha": 0 + }, + "line": { + "show": true, + "width": 1 + }, + "nullValues": "asZero", + "points": { + "radius": 5, + "show": false + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "celsius" + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 8, + "x": 8, + "y": 15 + }, + "id": 13, + "options": { + "graph": { + "realTimeUpdates": false + }, + "legend": { + "asTable": false, + "isVisible": true, + "placement": "under" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.2.0-pre", + "targets": [ + { + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + }, + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "10,25,null,null,50,10" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "As zero", + "transformations": [], + "type": "graph3" + }, + { + "datasource": "gdev-testdata", + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "axis": { + "grid": true, + "label": "Temperature", + "side": 3, + "width": 60 + }, + "bars": { + "show": false + }, + "fill": { + "alpha": 0 + }, + "line": { + "show": true, + "width": 1 + }, + "nullValues": "connected", + "points": { + "radius": 8, + "show": true + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "celsius" + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 8, + "x": 16, + "y": 15 + }, + "id": 12, + "options": { + "graph": { + "realTimeUpdates": false + }, + "legend": { + "asTable": false, + "isVisible": true, + "placement": "under" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.2.0-pre", + "targets": [ + { + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + }, + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "10,25,null,null,50,10" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Connected", + "transformations": [], + "type": "graph3" + } + ], + "title": "Null values rendering", + "type": "row" + }, + { + "collapsed": true, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 15 + }, + "id": 15, + "panels": [ + { + "datasource": "gdev-testdata", + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "axis": { + "grid": true, + "label": "Temperature", + "side": 3, + "width": 60 + }, + "bars": { + "show": false + }, + "fill": { + "alpha": 0 + }, + "line": { + "show": true, + "width": 1 + }, + "nullValues": "null", + "points": { + "radius": 8, + "show": true + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "celsius" + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 9, + "options": { + "graph": { + "realTimeUpdates": false + }, + "legend": { + "asTable": false, + "isVisible": true, + "placement": "under" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.2.0-pre", + "targets": [ + { + "refId": "C", + "scenarioId": "csv_metric_values", + "stringInput": "1,20,90,30,5,0" + }, + { + "refId": "A", + "scenarioId": "csv_metric_values", + "stringInput": "10,25,40" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Diff number of datapoints", + "transformations": [], + "type": "graph3" + } + ], + "title": "Misaligned data", + "type": "row" + }, + { + "collapsed": true, + "datasource": null, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 38, + "panels": [ + { + "datasource": "gdev-testdata", + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed" + }, + "custom": { + "align": null, + "axis": { + "grid": true, + "label": "", + "side": 3, + "width": 80 + }, + "bars": { + "show": false + }, + "fill": { + "alpha": 0.2 + }, + "line": { + "show": false, + "width": 1 + }, + "nullValues": "null", + "points": { + "radius": 4, + "show": false + } + }, + "decimals": 2, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "celsius" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "B-series" + }, + "properties": [ + { + "id": "unit", + "value": "fahrenheit" + }, + { + "id": "custom.axis.side", + "value": 1 + }, + { + "id": "custom.line.show", + "value": true + }, + { + "id": "custom.axis.label", + "value": "Temperature (F)" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "C-series" + }, + "properties": [ + { + "id": "unit", + "value": "kelvin" + }, + { + "id": "custom.axis.label", + "value": "Temperature (K)" + }, + { + "id": "custom.axis.side", + "value": 1 + }, + { + "id": "custom.axis.grid", + "value": false + }, + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 11, + "w": 12, + "x": 0, + "y": 17 + }, + "id": 5, + "options": { + "graph": { + "realTimeUpdates": false + }, + "legend": { + "asTable": false, + "isVisible": true, + "placement": "bottom" + }, + "tooltipOptions": { + "mode": "single" + } + }, + "pluginVersion": "7.2.0-pre", + "targets": [ + { + "refId": "C", + "scenarioId": "streaming_client", + "stream": { + "bands": 1, + "noise": 2.2, + "speed": 250, + "spread": 3.5, + "type": "signal" + }, + "stringInput": "" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Graph ng", + "transformations": [], + "type": "graph3" + } + ], + "title": "Streaming", + "type": "row" + } + ], + "refresh": false, + "schemaVersion": 26, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + }, + "timezone": "Africa/Accra", + "title": "Panel Tests - Graph NG", + "uid": "TkZXxlNG3", + "version": 61, + "tags": ["gdev", "panel-tests", "graph"] +} diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index b695312b2a0..e4df99a3be0 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -137,7 +137,6 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra // Anything in the field config that's not set by the datasource // will be filled in by panel's field configuration setFieldConfigDefaults(config, source.defaults, context); - // Find any matching rules and then override for (const rule of override) { if (rule.match(field, frame, options.data!)) { @@ -286,9 +285,9 @@ const processFieldConfigValue = ( context: FieldOverrideEnv ) => { const currentConfig = get(destination, fieldConfigProperty.path); - if (currentConfig === null || currentConfig === undefined) { const item = context.fieldConfigRegistry.getIfExists(fieldConfigProperty.id); + // console.log(item); if (!item) { return; } diff --git a/packages/grafana-data/src/utils/OptionsUIBuilders.ts b/packages/grafana-data/src/utils/OptionsUIBuilders.ts index 52e4aef73ef..98138e6b886 100644 --- a/packages/grafana-data/src/utils/OptionsUIBuilders.ts +++ b/packages/grafana-data/src/utils/OptionsUIBuilders.ts @@ -17,6 +17,7 @@ import { UnitFieldConfigSettings, unitOverrideProcessor, } from '../field'; +import { FieldColor } from '../types'; /** * Fluent API for declarative creation of field config option editors @@ -91,7 +92,7 @@ export class FieldConfigEditorBuilder extends OptionsUIRegistryBuilder } addColorPicker( - config: FieldConfigEditorConfig + config: FieldConfigEditorConfig ) { return this.addCustomEditor({ ...config, diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 927417fb710..94c1a2b013e 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -67,7 +67,8 @@ "react-table": "7.0.0", "react-transition-group": "4.3.0", "slate": "0.47.8", - "tinycolor2": "1.4.1" + "tinycolor2": "1.4.1", + "uplot": "1.1.2" }, "devDependencies": { "@rollup/plugin-commonjs": "11.0.2", diff --git a/packages/grafana-ui/src/components/Chart/Tooltip.test.tsx b/packages/grafana-ui/src/components/Chart/Tooltip.test.tsx index 28bdffb6793..2162c3f82ac 100644 --- a/packages/grafana-ui/src/components/Chart/Tooltip.test.tsx +++ b/packages/grafana-ui/src/components/Chart/Tooltip.test.tsx @@ -93,7 +93,7 @@ describe('Chart Tooltip', () => { // +--------------------++------+ // |origin| // +------+ - expect(styleAttribute).toContain('translate3d(890px, 590px, 0)'); + expect(styleAttribute).toContain('translate3d(910px, 610px, 0)'); }); }); }); diff --git a/packages/grafana-ui/src/components/Chart/Tooltip.tsx b/packages/grafana-ui/src/components/Chart/Tooltip.tsx index fa8cef3782b..97fa90f2190 100644 --- a/packages/grafana-ui/src/components/Chart/Tooltip.tsx +++ b/packages/grafana-ui/src/components/Chart/Tooltip.tsx @@ -5,7 +5,7 @@ import { Dimensions, TimeZone } from '@grafana/data'; import { FlotPosition } from '../Graph/types'; import { TooltipContainer } from './TooltipContainer'; -export type TooltipMode = 'single' | 'multi'; +export type TooltipMode = 'single' | 'multi' | 'none'; // Describes active dimensions user interacts with // It's a key-value pair where: diff --git a/packages/grafana-ui/src/components/Chart/TooltipContainer.tsx b/packages/grafana-ui/src/components/Chart/TooltipContainer.tsx index daeab950eb4..cea5c2416d7 100644 --- a/packages/grafana-ui/src/components/Chart/TooltipContainer.tsx +++ b/packages/grafana-ui/src/components/Chart/TooltipContainer.tsx @@ -45,17 +45,17 @@ export const TooltipContainer: React.FC = ({ position, of const xOverflow = width - (position.x + measurement.width); const yOverflow = height - (position.y + measurement.height); if (xOverflow < 0) { - xO = measurement.width + offset.x; + xO = measurement.width; } if (yOverflow < 0) { - yO = measurement.height + offset.y; + yO = measurement.height; } } setPlacement({ - x: position.x - xO, - y: position.y - yO, + x: position.x + offset.x - xO, + y: position.y + offset.y - yO, }); }, [tooltipRef, position]); diff --git a/packages/grafana-ui/src/components/Graph/GraphLegendItem.tsx b/packages/grafana-ui/src/components/Graph/GraphLegendItem.tsx index 0e2ff361de4..23cd58e4429 100644 --- a/packages/grafana-ui/src/components/Graph/GraphLegendItem.tsx +++ b/packages/grafana-ui/src/components/Graph/GraphLegendItem.tsx @@ -24,7 +24,7 @@ export const GraphLegendListItem: React.FunctionComponent onLabelClick, }) => { const theme = useContext(ThemeContext); - + const styles = getStyles(theme); return ( <> onLabelClick(item, event); } }} - className={css` - cursor: pointer; - white-space: pre-wrap; - color: ${!item.isVisible && theme.colors.linkDisabled}; - `} + className={cx(styles.label, !item.isVisible && styles.labelDisabled)} > {item.label} @@ -61,6 +57,7 @@ export const GraphLegendListItem: React.FunctionComponent const getStyles = stylesFactory((theme: GrafanaTheme) => { return { row: css` + label: LegendRow; font-size: ${theme.typography.size.sm}; td { padding: ${theme.spacing.xxs} ${theme.spacing.sm}; @@ -68,9 +65,14 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { } `, label: css` + label: LegendLabel; cursor: pointer; white-space: nowrap; `, + labelDisabled: css` + label: LegendLabelDisabled; + color: ${theme.colors.linkDisabled}; + `, itemWrapper: css` display: flex; white-space: nowrap; diff --git a/packages/grafana-ui/src/components/Graph/GraphTooltip/SeriesTable.tsx b/packages/grafana-ui/src/components/Graph/GraphTooltip/SeriesTable.tsx index 8d691063bf9..22012c1ccd7 100644 --- a/packages/grafana-ui/src/components/Graph/GraphTooltip/SeriesTable.tsx +++ b/packages/grafana-ui/src/components/Graph/GraphTooltip/SeriesTable.tsx @@ -5,7 +5,7 @@ import { css, cx } from 'emotion'; import { SeriesIcon } from '../../Legend/SeriesIcon'; import { useTheme } from '../../../themes'; -interface SeriesTableRowProps { +export interface SeriesTableRowProps { color?: string; label?: string; value: string | GraphSeriesValue; diff --git a/packages/grafana-ui/src/components/OptionsUI/color.tsx b/packages/grafana-ui/src/components/OptionsUI/color.tsx index 2b907cd10fb..cf2c15a9811 100644 --- a/packages/grafana-ui/src/components/OptionsUI/color.tsx +++ b/packages/grafana-ui/src/components/OptionsUI/color.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { FieldConfigEditorProps, ColorFieldConfigSettings, GrafanaTheme, getColorFromHexRgbOrName, + FieldColor, } from '@grafana/data'; import { ColorPicker } from '../ColorPicker/ColorPicker'; import { getTheme, stylesFactory } from '../../themes'; @@ -11,7 +12,8 @@ import { Icon } from '../Icon/Icon'; import { css } from 'emotion'; import { ColorPickerTrigger } from '../ColorPicker/ColorPickerTrigger'; -export const ColorValueEditor: React.FC> = ({ +// Supporting FixedColor only currently +export const ColorValueEditor: React.FC> = ({ value, onChange, item, @@ -20,10 +22,17 @@ export const ColorValueEditor: React.FC { + onChange({ ...value, fixedColor: color }); + }, + [value] + ); return ( - + {({ ref, showColorPicker, hideColorPicker }) => { return (
@@ -32,11 +41,11 @@ export const ColorValueEditor: React.FC
- {value ?? settings?.textWhenUndefined ?? 'Pick Color'} + {color ?? settings?.textWhenUndefined ?? 'Pick Color'}
{value && settings?.allowUndefined && ( onChange(undefined)} /> diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index a75963c3715..418ab7eb5ea 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -69,6 +69,11 @@ export { BigValueTextMode, } from './BigValue/BigValue'; +export { GraphCustomFieldConfig } from './uPlot/types'; +export { UPlotChart } from './uPlot/Plot'; +export { Canvas } from './uPlot/Canvas'; +export * from './uPlot/plugins'; + export { Gauge } from './Gauge/Gauge'; export { Graph } from './Graph/Graph'; export { GraphLegend } from './Graph/GraphLegend'; diff --git a/packages/grafana-ui/src/components/uPlot/Canvas.tsx b/packages/grafana-ui/src/components/uPlot/Canvas.tsx new file mode 100644 index 00000000000..085af85411b --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/Canvas.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { usePlotContext } from './context'; + +interface CanvasProps { + width?: number; + height?: number; +} + +// Ref element to render the uPlot canvas to +// This is a required child of Plot component! +export const Canvas: React.FC = ({ width, height }) => { + const plot = usePlotContext(); + if (!plot) { + return null; + } + + return
; +}; + +Canvas.displayName = 'Canvas'; diff --git a/packages/grafana-ui/src/components/uPlot/Plot.tsx b/packages/grafana-ui/src/components/uPlot/Plot.tsx new file mode 100644 index 00000000000..3bb548709e1 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/Plot.tsx @@ -0,0 +1,142 @@ +import 'uplot/dist/uPlot.min.css'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { css } from 'emotion'; +import uPlot from 'uplot'; +import { useTheme } from '../../themes'; +import { buildPlotContext, PlotContext } from './context'; +import { buildPlotConfig, pluginLog, preparePlotData, shouldReinitialisePlot } from './utils'; +import { usePlotPlugins } from './hooks'; +import { PlotProps } from './types'; + +// uPlot abstraction responsible for plot initialisation, setup and refresh +// Receives a data frame that is x-axis aligned, as of https://github.com/leeoniya/uPlot/tree/master/docs#data-format +// Exposes contexts for plugins registration and uPlot instance access +export const UPlotChart: React.FC = props => { + const theme = useTheme(); + const canvasRef = useRef(null); + + // instance of uPlot, exposed via PlotContext + const [plotInstance, setPlotInstance] = useState(); + + // Array with current plot data points, calculated when data frame is passed to a plot + // const [plotData, setPlotData] = useState(); + // uPlot config + const [currentPlotConfig, setCurrentPlotConfig] = useState(); + + // uPlot plugins API hook + const { arePluginsReady, plugins, registerPlugin } = usePlotPlugins(); + + // Main function initialising uPlot. If final config is not settled it will do nothing + // Will destroy existing uPlot instance + const initPlot = useCallback(() => { + if (!currentPlotConfig || !canvasRef?.current) { + return; + } + + if (plotInstance) { + pluginLog('uPlot core', false, 'destroying existing instance due to reinitialisation'); + plotInstance.destroy(); + } + + const data = preparePlotData(props.data); + + pluginLog('uPlot core', false, 'initialized with', data, currentPlotConfig); + + setPlotInstance(new uPlot(currentPlotConfig, data, canvasRef.current)); + }, [props, currentPlotConfig, arePluginsReady, canvasRef.current, plotInstance]); + + const hasConfigChanged = useCallback(() => { + const config = buildPlotConfig(props, props.data, plugins, theme); + if (!currentPlotConfig) { + return false; + } + + return shouldReinitialisePlot(currentPlotConfig, config); + }, [props, props.data, currentPlotConfig]); + + // Initialise uPlot when config changes + useEffect(() => { + if (!currentPlotConfig) { + return; + } + initPlot(); + }, [currentPlotConfig]); + + // Destroy uPlot on when components unmounts + useEffect(() => { + return () => { + if (plotInstance) { + pluginLog('uPlot core', false, 'destroying existing instance due to unmount'); + plotInstance.destroy(); + } + }; + }, [plotInstance]); + + // Effect performed when all plugins have registered. Final config is set triggering plot initialisation + useEffect(() => { + if (!canvasRef) { + throw new Error('Cannot render graph without canvas! Render Canvas as a child of Plot component.'); + } + + if (!arePluginsReady) { + return; + } + + if (canvasRef.current) { + setCurrentPlotConfig(buildPlotConfig(props, props.data, plugins, theme)); + } + + return () => { + if (plotInstance) { + console.log('uPlot - destroy instance, unmount'); + plotInstance.destroy(); + } + }; + }, [arePluginsReady]); + + // When data changes try to be clever about config updates, needs some more love + useEffect(() => { + const data = preparePlotData(props.data); + const config = buildPlotConfig(props, props.data, plugins, theme); + + // See if series configs changes, re-initialise if necessary + // this is a minimal check, need to update for field config cleverness ;) + if (hasConfigChanged()) { + setCurrentPlotConfig(config); // will trigger uPlot reinitialisation + return; + } else { + pluginLog('uPlot core', true, 'updating plot data(throttled log!)'); + // If config hasn't changed just update uPlot's data + plotInstance?.setData(data); + } + }, [props.data, props.timeRange]); + + // When size props changed update plot size synchronously + useLayoutEffect(() => { + if (plotInstance) { + plotInstance.setSize({ + width: props.width, + height: props.height, + }); + } + }, [plotInstance, props.width, props.height]); + + // Memoize plot context + const plotCtx = useMemo(() => { + return buildPlotContext(registerPlugin, canvasRef, props.data, plotInstance); + }, [registerPlugin, canvasRef, props.data, plotInstance]); + + return ( + +
+ {props.children} +
+
+ ); +}; diff --git a/packages/grafana-ui/src/components/uPlot/context.ts b/packages/grafana-ui/src/components/uPlot/context.ts new file mode 100644 index 00000000000..896ae97e0e4 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/context.ts @@ -0,0 +1,162 @@ +import React, { useCallback, useContext } from 'react'; +import uPlot from 'uplot'; +import { PlotPlugin } from './types'; +import { DataFrame, Field, FieldConfig } from '@grafana/data'; + +interface PlotCanvasContextType { + // canvas size css pxs + width: number; + height: number; + // plotting area bbox, css pxs + plot: { + width: number; + height: number; + top: number; + left: number; + }; +} + +interface PlotContextType { + u?: uPlot; + series?: uPlot.Series[]; + canvas?: PlotCanvasContextType; + canvasRef: any; + registerPlugin: (plugin: PlotPlugin) => () => void; + data: DataFrame; +} + +type PlotPluginsContextType = { + registerPlugin: (plugin: PlotPlugin) => () => void; +}; + +export const PlotContext = React.createContext(null); + +// Exposes uPlot instance and bounding box of the entire canvas and plot area +export const usePlotContext = (): PlotContextType | null => { + return useContext(PlotContext); +}; + +const throwWhenNoContext = (name: string) => { + throw new Error(`${name} must be used within PlotContext`); +}; + +// Exposes API for registering uPlot plugins +export const usePlotPluginContext = (): PlotPluginsContextType => { + const ctx = useContext(PlotContext); + if (!ctx) { + throwWhenNoContext('usePlotPluginContext'); + } + return { + registerPlugin: ctx!.registerPlugin, + }; +}; + +interface PlotDataAPI { + /** Data frame passed to graph, x-axis aligned */ + data: DataFrame; + /** Returns field by index */ + getField: (idx: number) => Field; + /** Returns x-axis fields */ + getXAxisFields: () => Field[]; + /** Returns x-axis fields */ + getYAxisFields: () => Field[]; + /** Returns field value by field and value index */ + getFieldValue: (fieldIdx: number, rowIdx: number) => any; + /** Returns field config by field index */ + getFieldConfig: (fieldIdx: number) => FieldConfig; +} + +export const usePlotData = (): PlotDataAPI => { + const ctx = useContext(PlotContext); + + const getField = useCallback( + (idx: number) => { + if (!ctx) { + throwWhenNoContext('usePlotData'); + } + return ctx!.data.fields[idx]; + }, + [ctx] + ); + + const getFieldConfig = useCallback( + (idx: number) => { + const field: Field = getField(idx); + return field.config; + }, + [ctx] + ); + + const getFieldValue = useCallback( + (fieldIdx: number, rowIdx: number) => { + const field: Field = getField(fieldIdx); + return field.values.get(rowIdx); + }, + [ctx] + ); + + const getXAxisFields = useCallback(() => { + // by uPlot convention x-axis is always first field + // this may change when we introduce non-time x-axis and multiple x-axes (https://leeoniya.github.io/uPlot/demos/time-periods.html) + return [getField(0)]; + }, [ctx]); + + const getYAxisFields = useCallback(() => { + if (!ctx) { + throwWhenNoContext('usePlotData'); + } + // by uPlot convention x-axis is always first field + // this may change when we introduce non-time x-axis and multiple x-axes (https://leeoniya.github.io/uPlot/demos/time-periods.html) + return ctx!.data.fields.slice(1); + }, [ctx]); + + if (!ctx) { + throwWhenNoContext('usePlotData'); + } + + return { + data: ctx!.data, + getField, + getFieldValue, + getFieldConfig, + getXAxisFields, + getYAxisFields, + }; +}; + +// Returns bbox of the plot canvas (only the graph, no axes) +export const usePlotCanvas = (): PlotCanvasContextType | null => { + const ctx = usePlotContext(); + if (!ctx) { + throwWhenNoContext('usePlotCanvas'); + } + + return ctx!.canvas || null; +}; + +export const buildPlotContext = ( + registerPlugin: any, + canvasRef: any, + data: DataFrame, + u?: uPlot +): PlotContextType | null => { + return { + u, + series: u?.series, + canvas: u + ? { + width: u.width, + height: u.height, + plot: { + width: u.bbox.width / window.devicePixelRatio, + height: u.bbox.height / window.devicePixelRatio, + top: u.bbox.top / window.devicePixelRatio, + left: u.bbox.left / window.devicePixelRatio, + }, + } + : undefined, + registerPlugin, + canvasRef, + data, + }; +}; diff --git a/packages/grafana-ui/src/components/uPlot/hooks.ts b/packages/grafana-ui/src/components/uPlot/hooks.ts new file mode 100644 index 00000000000..64cae7afcd2 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/hooks.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { PlotPlugin } from './types'; +import { pluginLog } from './utils'; + +export const usePlotPlugins = () => { + /** + * Map of registered plugins (via children) + * Used to build uPlot plugins config + */ + const [plugins, setPlugins] = useState>({}); + // const registeredPlugins = useRef(0); + + // arePluginsReady determines whether or not all plugins has already registered and uPlot should be initialised + const [arePluginsReady, setPluginsReady] = useState(false); + + const cancellationToken = useRef(); + + const checkPluginsReady = useCallback(() => { + if (cancellationToken.current) { + window.cancelAnimationFrame(cancellationToken.current); + } + + /** + * After registering plugin let's wait for all code to complete to set arePluginsReady to true. + * If any other plugin will try to register, the previously scheduled call will be canceled + * and arePluginsReady will be deferred to next animation frame. + */ + cancellationToken.current = window.requestAnimationFrame(function() { + setPluginsReady(true); + }); + }, [cancellationToken, setPluginsReady]); + + const registerPlugin = useCallback( + (plugin: PlotPlugin) => { + pluginLog(plugin.id, false, 'register'); + + if (plugins.hasOwnProperty(plugin.id)) { + throw new Error(`${plugin.id} that is already registered`); + } + + setPlugins(plugs => { + return { + ...plugs, + [plugin.id]: plugin, + }; + }); + checkPluginsReady(); + + return () => { + setPlugins(p => { + pluginLog(plugin.id, false, 'unregister'); + delete p[plugin.id]; + return { + ...p, + }; + }); + }; + }, + [setPlugins] + ); + + // When uPlot mounts let's check if there are any plugins pending registration + useEffect(() => { + checkPluginsReady(); + return () => { + if (cancellationToken.current) { + window.cancelAnimationFrame(cancellationToken.current); + } + }; + }, []); + + return { + arePluginsReady, + plugins, + registerPlugin, + }; +}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/AnnotationsEditorPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/AnnotationsEditorPlugin.tsx new file mode 100644 index 00000000000..524235b103e --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/AnnotationsEditorPlugin.tsx @@ -0,0 +1,56 @@ +import React, { useRef } from 'react'; +import { SelectionPlugin } from './SelectionPlugin'; +import { css } from 'emotion'; +import { Button } from '../../Button'; +import useClickAway from 'react-use/lib/useClickAway'; + +interface AnnotationsEditorPluginProps { + onAnnotationCreate: () => void; +} + +export const AnnotationsEditorPlugin: React.FC = ({ onAnnotationCreate }) => { + const pluginId = 'AnnotationsEditorPlugin'; + + return ( + { + console.log(selection); + }} + lazy + > + {({ selection, clearSelection }) => { + return ; + }} + + ); +}; + +const AnnotationEditor: React.FC = ({ onClose, selection }) => { + const ref = useRef(null); + + useClickAway(ref, () => { + if (onClose) { + onClose(); + } + }); + + return ( +
+
+ Annotations editor maybe? + +
+
+ ); +}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/ClickPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/ClickPlugin.tsx new file mode 100644 index 00000000000..b6927f3d08a --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/ClickPlugin.tsx @@ -0,0 +1,126 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { Global, css as cssCore } from '@emotion/core'; + +import { PlotPluginProps } from '../types'; +import { usePlotPluginContext } from '../context'; +import { pluginLog } from '../utils'; +import { CursorPlugin } from './CursorPlugin'; + +interface ClickPluginAPI { + point: { seriesIdx: number | null; dataIdx: number | null }; + coords: { + // coords relative to plot canvas, css px + plotCanvas: Coords; + // coords relative to viewport , css px + viewport: Coords; + }; + // coords relative to plot canvas, css px + clearSelection: () => void; +} + +interface ClickPluginProps extends PlotPluginProps { + onClick: (e: { seriesIdx: number | null; dataIdx: number | null }) => void; + children: (api: ClickPluginAPI) => React.ReactElement | null; +} + +interface Coords { + x: number; + y: number; +} + +// Exposes API for Graph click interactions +export const ClickPlugin: React.FC = ({ id, onClick, children }) => { + const pluginId = `ClickPlugin:${id}`; + + const pluginsApi = usePlotPluginContext(); + const [point, setPoint] = useState<{ seriesIdx: number | null; dataIdx: number | null } | null>(null); + + const clearSelection = useCallback(() => { + pluginLog(pluginId, false, 'clearing click selection'); + setPoint(null); + }, [setPoint]); + + useEffect(() => { + const unregister = pluginsApi.registerPlugin({ + id: pluginId, + hooks: { + init: u => { + pluginLog(pluginId, false, 'init'); + + // for naive click&drag check + let isClick = false; + // REF: https://github.com/leeoniya/uPlot/issues/239 + let pts = Array.from(u.root.querySelectorAll('.u-cursor-pt')); + const plotCanvas = u.root.querySelector('.u-over'); + + plotCanvas!.addEventListener('mousedown', (e: MouseEvent) => { + isClick = true; + }); + plotCanvas!.addEventListener('mousemove', (e: MouseEvent) => { + isClick = false; + }); + + // TODO: remove listeners on unmount + plotCanvas!.addEventListener('mouseup', (e: MouseEvent) => { + if (!isClick) { + setPoint(null); + return; + } + isClick = true; + pluginLog(pluginId, false, 'canvas click'); + + if (e.target) { + const target = e.target as HTMLElement; + if (!target.classList.contains('u-cursor-pt')) { + setPoint({ seriesIdx: null, dataIdx: null }); + } + } + }); + + if (pts.length > 0) { + pts.forEach((pt, i) => { + // TODO: remove listeners on unmount + pt.addEventListener('click', e => { + const seriesIdx = i + 1; + const dataIdx = u.cursor.idx; + + pluginLog(id, false, seriesIdx, dataIdx); + setPoint({ seriesIdx, dataIdx: dataIdx || null }); + onClick({ seriesIdx, dataIdx: dataIdx || null }); + }); + }); + } + }, + }, + }); + + return () => { + unregister(); + }; + }, []); + + return ( + <> + + + {({ coords }) => { + if (!point) { + return null; + } + + return children({ + point, + coords, + clearSelection, + }); + }} + + + ); +}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/ContextMenuPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/ContextMenuPlugin.tsx new file mode 100644 index 00000000000..2668800c4a6 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/ContextMenuPlugin.tsx @@ -0,0 +1,69 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { ClickPlugin } from './ClickPlugin'; +import { Portal } from '../../Portal/Portal'; +import { css } from 'emotion'; +import useClickAway from 'react-use/lib/useClickAway'; + +interface ContextMenuPluginProps { + onOpen?: () => void; + onClose?: () => void; +} + +export const ContextMenuPlugin: React.FC = ({ onClose }) => { + const [isOpen, setIsOpen] = useState(false); + + const onClick = useCallback(() => { + setIsOpen(!isOpen); + }, [setIsOpen]); + + return ( + + {({ point, coords, clearSelection }) => { + return ( + + { + clearSelection(); + if (onClose) { + onClose(); + } + }} + /> + + ); + }} + + ); +}; + +interface ContextMenuProps { + onClose?: () => void; + selection: any; +} + +const ContextMenu: React.FC = ({ onClose, selection }) => { + const ref = useRef(null); + + useClickAway(ref, () => { + if (onClose) { + onClose(); + } + }); + + return ( +
+ Point: {JSON.stringify(selection.point)}
+ Viewport coords: {JSON.stringify(selection.coords.viewport)} +
+ ); +}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/CursorPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/CursorPlugin.tsx new file mode 100644 index 00000000000..81b6dd025ad --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/CursorPlugin.tsx @@ -0,0 +1,112 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; + +import { PlotPluginProps } from '../types'; +import { pluginLog } from '../utils'; +import { usePlotPluginContext } from '../context'; + +interface CursorPluginAPI { + focusedSeriesIdx: number | null; + focusedPointIdx: number | null; + + coords: { + // coords relative to plot canvas, css px + plotCanvas: Coords; + // coords relative to viewport , css px + viewport: Coords; + }; +} + +interface CursorPluginProps extends PlotPluginProps { + onMouseMove?: () => void; // anything? + children: (api: CursorPluginAPI) => React.ReactElement | null; + // on what interaction position should be captures + capture?: 'mousemove' | 'mousedown'; + // should the position be persisted when user leaves canvas area + lock?: boolean; +} + +interface Coords { + x: number; + y: number; +} + +// Exposes API for Graph cursor position +export const CursorPlugin: React.FC = ({ id, children, capture = 'mousemove', lock = false }) => { + const pluginId = `CursorPlugin:${id}`; + const plotCanvas = useRef(null); + const plotCanvasBBox = useRef({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }); + const pluginsApi = usePlotPluginContext(); + + // state exposed to the consumers, maybe better implement as CursorPlugin? + const [focusedSeriesIdx, setFocusedSeriesIdx] = useState(null); + const [focusedPointIdx, setFocusedPointIdx] = useState(null); + const [coords, setCoords] = useState<{ viewport: Coords; plotCanvas: Coords } | null>(null); + + const clearCoords = useCallback(() => { + setCoords(null); + }, [setCoords]); + + useEffect(() => { + pluginLog(pluginId, true, `Focused series: ${focusedSeriesIdx}, focused point: ${focusedPointIdx}`); + }, [focusedPointIdx, focusedSeriesIdx]); + + useEffect(() => { + if (plotCanvas && plotCanvas.current) { + plotCanvasBBox.current = plotCanvas.current.getBoundingClientRect(); + } + }, [plotCanvas.current]); + + // on mount - init plugin + useEffect(() => { + const onMouseCapture = (e: MouseEvent) => { + setCoords({ + plotCanvas: { + x: e.clientX - plotCanvasBBox.current.left, + y: e.clientY - plotCanvasBBox.current.top, + }, + viewport: { + x: e.clientX, + y: e.clientY, + }, + }); + }; + + const unregister = pluginsApi.registerPlugin({ + id: pluginId, + hooks: { + init: u => { + // @ts-ignore + plotCanvas.current = u.root.querySelector('.u-over'); + // @ts-ignore + plotCanvas.current.addEventListener(capture, onMouseCapture); + if (!lock) { + // @ts-ignore + plotCanvas.current.addEventListener('mouseleave', clearCoords); + } + }, + setCursor: u => { + setFocusedPointIdx(u.cursor.idx === undefined ? null : u.cursor.idx); + }, + setSeries: (u, idx) => { + setFocusedSeriesIdx(idx); + }, + }, + }); + + return () => { + if (plotCanvas && plotCanvas.current) { + plotCanvas.current.removeEventListener(capture, onMouseCapture); + } + unregister(); + }; + }, []); + + // only render children if we are interacting with the canvas + return coords + ? children({ + focusedSeriesIdx, + focusedPointIdx, + coords, + }) + : null; +}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/LegendPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/LegendPlugin.tsx new file mode 100644 index 00000000000..9299ff0f400 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/LegendPlugin.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { GraphCustomFieldConfig, GraphLegend, LegendDisplayMode, LegendItem } from '../..'; +import { usePlotData } from '../context'; +import { FieldType, getColorFromHexRgbOrName, getFieldDisplayName } from '@grafana/data'; +import { colors } from '../../../utils'; + +export type LegendPlacement = 'top' | 'bottom' | 'left' | 'right'; + +interface LegendPluginProps { + placement: LegendPlacement; + displayMode?: LegendDisplayMode; +} + +export const LegendPlugin: React.FC = ({ placement, displayMode = LegendDisplayMode.List }) => { + const { data } = usePlotData(); + + const legendItems: LegendItem[] = []; + + let seriesIdx = 0; + + for (let i = 0; i < data.fields.length; i++) { + const field = data.fields[i]; + + if (field.type === FieldType.time) { + continue; + } + legendItems.push({ + color: + field.config.color && field.config.color.fixedColor + ? getColorFromHexRgbOrName(field.config.color.fixedColor) + : colors[seriesIdx], + label: getFieldDisplayName(field, data), + isVisible: true, + //flot vs uPlot differences + yAxis: (field.config.custom as GraphCustomFieldConfig).axis?.side === 1 ? 3 : 1, + }); + seriesIdx++; + } + + return ( + + ); +}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/SelectionPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/SelectionPlugin.tsx new file mode 100644 index 00000000000..39e2489971a --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/SelectionPlugin.tsx @@ -0,0 +1,88 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { PlotPluginProps } from '../types'; +import { usePlotCanvas, usePlotPluginContext } from '../context'; +import { pluginLog } from '../utils'; + +interface Selection { + min: number; + max: number; + + // selection bounding box, relative to canvas + bbox: { + top: number; + left: number; + width: number; + height: number; + }; +} + +interface SelectionPluginAPI { + selection: Selection; + clearSelection: () => void; +} + +interface SelectionPluginProps extends PlotPluginProps { + onSelect: (selection: Selection) => void; + onDismiss?: () => void; + // when true onSelect won't be fired when selection ends + // useful for plugins that need to do sth with the selected region, i.e. annotations editor + lazy?: boolean; + children?: (api: SelectionPluginAPI) => JSX.Element; +} + +export const SelectionPlugin: React.FC = ({ onSelect, onDismiss, lazy, id, children }) => { + const pluginId = `SelectionPlugin:${id}`; + const pluginsApi = usePlotPluginContext(); + const canvas = usePlotCanvas(); + + const [selection, setSelection] = useState(null); + // + useEffect(() => { + if (!lazy && selection) { + pluginLog(pluginId, false, 'selected', selection); + onSelect(selection); + } + }, [selection]); + + const clearSelection = useCallback(() => { + setSelection(null); + }, [setSelection]); + + useEffect(() => { + pluginsApi.registerPlugin({ + id: pluginId, + hooks: { + setSelect: u => { + const min = u.posToVal(u.select.left, 'x'); + const max = u.posToVal(u.select.left + u.select.width, 'x'); + + setSelection({ + min, + max, + bbox: { + left: u.bbox.left / window.devicePixelRatio + u.select.left, + top: u.bbox.top / window.devicePixelRatio, + height: u.bbox.height / window.devicePixelRatio, + width: u.select.width, + }, + }); + }, + }, + }); + + return () => { + if (onDismiss) { + onDismiss(); + } + }; + }, []); + + if (!children || !canvas || !selection) { + return null; + } + + return children({ + selection, + clearSelection, + }); +}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx new file mode 100644 index 00000000000..db3ccdfc3d7 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { Portal } from '../../Portal/Portal'; +import { usePlotContext, usePlotData } from '../context'; +import { CursorPlugin } from './CursorPlugin'; +import { SeriesTable, SeriesTableRowProps } from '../../Graph/GraphTooltip/SeriesTable'; +import { FieldType, formattedValueToString, getDisplayProcessor, getFieldDisplayName, TimeZone } from '@grafana/data'; +import { TooltipContainer } from '../../Chart/TooltipContainer'; +import { TooltipMode } from '../../Chart/Tooltip'; + +interface TooltipPluginProps { + mode?: TooltipMode; + timeZone: TimeZone; +} + +export const TooltipPlugin: React.FC = ({ mode = 'single', timeZone }) => { + const pluginId = 'PlotTooltip'; + const plotContext = usePlotContext(); + const { data, getField, getXAxisFields } = usePlotData(); + + const xAxisFields = getXAxisFields(); + // assuming single x-axis + const xAxisField = xAxisFields[0]; + const xAxisFmt = xAxisField.display || getDisplayProcessor({ field: xAxisField, timeZone }); + + return ( + + {({ focusedSeriesIdx, focusedPointIdx, coords }) => { + if (!plotContext || !plotContext.series) { + return null; + } + + let tooltip = null; + + // when no no cursor interaction + if (focusedPointIdx === null) { + return null; + } + + // when interacting with a point in single mode + if (mode === 'single' && focusedSeriesIdx !== null) { + const xVal = xAxisFmt(xAxisFields[0]!.values.get(focusedPointIdx)).text; + const field = getField(focusedSeriesIdx); + const fieldFmt = field.display || getDisplayProcessor({ field, timeZone }); + tooltip = ( + + ); + } + + if (mode === 'multi') { + const xVal = xAxisFmt(xAxisFields[0].values.get(focusedPointIdx)).text; + tooltip = ( + ((agg, f, i) => { + // skipping time field and non-numeric fields + if (f.type === FieldType.time || f.type !== FieldType.number) { + return agg; + } + + return [ + ...agg, + { + // stroke is typed as CanvasRenderingContext2D['strokeStyle'] - we are using strings only for now + color: plotContext.series![i].stroke as string, + label: getFieldDisplayName(f, data), + value: formattedValueToString(f.display!(f.values.get(focusedPointIdx!))), + isActive: focusedSeriesIdx === i, + }, + ]; + }, [])} + timestamp={xVal} + /> + ); + } + + if (!tooltip) { + return null; + } + + return ( + + + {tooltip} + + + ); + }} + + ); +}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx new file mode 100644 index 00000000000..3771e1a6813 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { SelectionPlugin } from './SelectionPlugin'; + +interface ZoomPluginProps { + onZoom: (range: { from: number; to: number }) => void; +} + +// min px width that triggers zoom +const MIN_ZOOM_DIST = 5; + +export const ZoomPlugin: React.FC = ({ onZoom }) => { + return ( + { + if (selection.bbox.width < MIN_ZOOM_DIST) { + return; + } + onZoom({ from: selection.min * 1000, to: selection.max * 1000 }); + }} + /> + ); +}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/index.ts b/packages/grafana-ui/src/components/uPlot/plugins/index.ts new file mode 100644 index 00000000000..c3af0bd1e2f --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/plugins/index.ts @@ -0,0 +1,7 @@ +export { ClickPlugin } from './ClickPlugin'; +export { SelectionPlugin } from './SelectionPlugin'; +export { ZoomPlugin } from './ZoomPlugin'; +export { AnnotationsEditorPlugin } from './AnnotationsEditorPlugin'; +export { ContextMenuPlugin } from './ContextMenuPlugin'; +export { TooltipPlugin } from './TooltipPlugin'; +export { LegendPlugin } from './LegendPlugin'; diff --git a/packages/grafana-ui/src/components/uPlot/renderPlugin.ts b/packages/grafana-ui/src/components/uPlot/renderPlugin.ts new file mode 100644 index 00000000000..9041dde21c1 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/renderPlugin.ts @@ -0,0 +1,76 @@ +import uPlot from 'uplot'; + +export function renderPlugin({ spikes = 4, outerRadius = 8, innerRadius = 4 } = {}) { + outerRadius *= devicePixelRatio; + innerRadius *= devicePixelRatio; + + // https://stackoverflow.com/questions/25837158/how-to-draw-a-star-by-using-canvas-html5 + function drawStar(ctx: any, cx: number, cy: number) { + let rot = (Math.PI / 2) * 3; + let x = cx; + let y = cy; + let step = Math.PI / spikes; + + ctx.beginPath(); + ctx.moveTo(cx, cy - outerRadius); + + for (let i = 0; i < spikes; i++) { + x = cx + Math.cos(rot) * outerRadius; + y = cy + Math.sin(rot) * outerRadius; + ctx.lineTo(x, y); + rot += step; + + x = cx + Math.cos(rot) * innerRadius; + y = cy + Math.sin(rot) * innerRadius; + ctx.lineTo(x, y); + rot += step; + } + + ctx.lineTo(cx, cy - outerRadius); + ctx.closePath(); + } + + function drawPointsAsStars(u: uPlot, i: number, i0: any, i1: any) { + let { ctx } = u; + let { stroke, scale } = u.series[i]; + + ctx.fillStyle = stroke as string; + + let j = i0; + + while (j <= i1) { + const val = u.data[i][j] as number; + const cx = Math.round(u.valToPos(u.data[0][j] as number, 'x', true)); + const cy = Math.round(u.valToPos(val, scale as string, true)); + + drawStar(ctx, cx, cy); + ctx.fill(); + + // const zy = Math.round(u.valToPos(0, scale as string, true)); + + // ctx.beginPath(); + // ctx.lineWidth = 3; + // ctx.moveTo(cx, cy - outerRadius); + // ctx.lineTo(cx, zy); + // ctx.stroke(); + // ctx.fill(); + + j++; + } + } + + return { + opts: (u: uPlot, opts: uPlot.Options) => { + opts.series.forEach((s, i) => { + if (i > 0) { + uPlot.assign(s, { + points: { + show: drawPointsAsStars, + }, + }); + } + }); + }, + hooks: {}, // can add callbacks here + }; +} diff --git a/packages/grafana-ui/src/components/uPlot/types.ts b/packages/grafana-ui/src/components/uPlot/types.ts new file mode 100644 index 00000000000..dbd6d81c26e --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/types.ts @@ -0,0 +1,63 @@ +import React from 'react'; +import uPlot from 'uplot'; +import { DataFrame, FieldColor, TimeRange, TimeZone } from '@grafana/data'; +import { NullValuesMode } from '../../../../../public/app/plugins/panel/graph3/types'; + +export enum MicroPlotAxisSide { + top = 0, + right = 1, + bottom = 2, + left = 3, +} + +interface AxisConfig { + label: string; + side: number; + grid: boolean; + width: number; +} + +interface LineConfig { + show: boolean; + width: number; + color: FieldColor; +} +interface PointConfig { + show: boolean; + radius: number; +} +interface BarsConfig { + show: boolean; +} +interface FillConfig { + alpha: number; +} + +export interface GraphCustomFieldConfig { + axis: AxisConfig; + line: LineConfig; + points: PointConfig; + bars: BarsConfig; + fill: FillConfig; + nullValues: NullValuesMode; +} + +export type PlotPlugin = { + id: string; + /** can mutate provided opts as necessary */ + opts?: (self: uPlot, opts: uPlot.Options) => void; + hooks: uPlot.PluginHooks; +}; + +export interface PlotPluginProps { + id: string; +} + +export interface PlotProps { + data: DataFrame; + width: number; + height: number; + timeRange: TimeRange; + timeZone: TimeZone; + children: React.ReactNode[]; +} diff --git a/packages/grafana-ui/src/components/uPlot/utils.test.ts b/packages/grafana-ui/src/components/uPlot/utils.test.ts new file mode 100644 index 00000000000..79fd38a3807 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/utils.test.ts @@ -0,0 +1,15 @@ +import { timeFormatToTemplate } from './utils'; + +describe('timeFormatToTemplate', () => { + it.each` + format | expected + ${'HH:mm:ss'} | ${'{HH}:{mm}:{ss}'} + ${'HH:mm'} | ${'{HH}:{mm}'} + ${'MM/DD HH:mm'} | ${'{MM}/{DD} {HH}:{mm}'} + ${'MM/DD'} | ${'{MM}/{DD}'} + ${'YYYY-MM'} | ${'{YYYY}-{MM}'} + ${'YYYY'} | ${'{YYYY}'} + `('should convert $format to $expected', ({ format, expected }) => { + expect(timeFormatToTemplate(format)).toEqual(expected); + }); +}); diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts new file mode 100644 index 00000000000..82c7b4dece1 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -0,0 +1,307 @@ +import throttle from 'lodash/throttle'; +import isEqual from 'lodash/isEqual'; +import omit from 'lodash/omit'; +import tinycolor from 'tinycolor2'; +import { + DataFrame, + FieldConfig, + FieldType, + formattedValueToString, + getColorFromHexRgbOrName, + getFieldDisplayName, + getTimeField, + getTimeZoneInfo, + GrafanaTheme, + rangeUtil, + RawTimeRange, + systemDateFormats, + TimeRange, +} from '@grafana/data'; +import { colors } from '../../utils'; +import uPlot from 'uplot'; +import { GraphCustomFieldConfig, PlotPlugin, PlotProps } from './types'; + +const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); + +const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g; +export const timeFormatToTemplate = (f: string) => { + return f.replace(ALLOWED_FORMAT_STRINGS_REGEX, match => `{${match}}`); +}; + +const timeStampsConfig = [ + [3600 * 24 * 365, '{YYYY}', 7, '{YYYY}'], + [3600 * 24 * 28, `{${timeFormatToTemplate(systemDateFormats.interval.month)}`, 7, '{MMM}\n{YYYY}'], + [ + 3600 * 24, + `{${timeFormatToTemplate(systemDateFormats.interval.day)}`, + 7, + `${timeFormatToTemplate(systemDateFormats.interval.day)}\n${timeFormatToTemplate(systemDateFormats.interval.year)}`, + ], + [ + 3600, + `{${timeFormatToTemplate(systemDateFormats.interval.minute)}`, + 4, + `${timeFormatToTemplate(systemDateFormats.interval.minute)}\n${timeFormatToTemplate( + systemDateFormats.interval.day + )}`, + ], + [ + 60, + `{${timeFormatToTemplate(systemDateFormats.interval.second)}`, + 4, + `${timeFormatToTemplate(systemDateFormats.interval.second)}\n${timeFormatToTemplate( + systemDateFormats.interval.day + )}`, + ], + [ + 1, + `:{ss}`, + 2, + `:{ss}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate( + systemDateFormats.interval.minute + )}`, + ], + [ + 1e-3, + ':{ss}.{fff}', + 2, + `:{ss}.{fff}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate( + systemDateFormats.interval.minute + )}`, + ], +]; + +export function rangeToMinMax(timeRange: RawTimeRange): [number, number] { + const v = rangeUtil.convertRawToRange(timeRange); + return [v.from.valueOf() / 1000, v.to.valueOf() / 1000]; +} + +// based on aligned data frames creates config for scales, axes and series +export const buildSeriesConfig = ( + data: DataFrame, + timeRange: TimeRange, + theme: GrafanaTheme +): { + series: uPlot.Series[]; + scales: Record; + axes: uPlot.Axis[]; +} => { + const series: uPlot.Series[] = [{}]; + const scales: Record = { + x: { + time: true, + // range: rangeToMinMax(timeRange.raw), + // auto: true + }, + }; + + const axes: uPlot.Axis[] = []; + + let { timeIndex } = getTimeField(data); + + if (timeIndex === undefined) { + timeIndex = 0; // assuming first field represents x-domain + scales.x.time = false; + } + + // x-axis + axes.push({ + show: true, + stroke: theme.colors.text, + grid: { + show: true, + stroke: theme.palette.gray4, + width: 1 / devicePixelRatio, + }, + values: timeStampsConfig, + }); + + let seriesIdx = 0; + + for (let i = 0; i < data.fields.length; i++) { + const field = data.fields[i]; + const config = field.config as FieldConfig; + const customConfig = config.custom; + console.log(customConfig); + const fmt = field.display ?? defaultFormatter; + + if (i === timeIndex || field.type !== FieldType.number) { + continue; + } + + const scale = config.unit || '__fixed'; + + if (!scales[scale]) { + scales[scale] = {}; + axes.push({ + scale, + label: config.custom?.axis?.label, + show: true, + size: config.custom?.axis?.width || 80, + stroke: theme.colors.text, + side: config.custom?.axis?.side || 3, + grid: { + show: config.custom?.axis?.grid, + stroke: theme.palette.gray4, + width: 1 / devicePixelRatio, + }, + values: (u, vals) => vals.map(v => formattedValueToString(fmt(v))), + }); + } + + const seriesColor = + customConfig?.line?.color && customConfig?.line?.color.fixedColor + ? getColorFromHexRgbOrName(customConfig.line?.color.fixedColor) + : colors[seriesIdx]; + + series.push({ + scale, + label: getFieldDisplayName(field, data), + stroke: seriesColor, + fill: customConfig?.fill?.alpha + ? tinycolor(seriesColor) + .setAlpha(customConfig?.fill?.alpha) + .toRgbString() + : undefined, + width: customConfig?.line?.show ? customConfig?.line?.width || 1 : 0, + points: { + show: customConfig?.points?.show, + size: customConfig?.points?.radius || 5, + }, + spanGaps: customConfig?.nullValues === 'connected', + }); + seriesIdx += 1; + } + + return { + scales, + series, + axes, + }; +}; + +export const buildPlotConfig = ( + props: PlotProps, + data: DataFrame, + plugins: Record, + theme: GrafanaTheme +): uPlot.Options => { + const seriesConfig = buildSeriesConfig(data, props.timeRange, theme); + let tzDate; + + // When plotting time series use correct timezone for timestamps + if (seriesConfig.scales.x.time) { + const tz = getTimeZoneInfo(props.timeZone, Date.now())?.ianaName; + if (tz) { + tzDate = (ts: number) => uPlot.tzDate(new Date(ts * 1e3), tz); + } + } + + return { + width: props.width, + height: props.height, + focus: { + alpha: 1, + }, + cursor: { + focus: { + prox: 30, + }, + }, + legend: { + show: false, + }, + plugins: Object.entries(plugins).map(p => ({ + hooks: p[1].hooks, + })), + hooks: {}, + tzDate, + ...seriesConfig, + }; +}; + +export const preparePlotData = (data: DataFrame): uPlot.AlignedData => { + const plotData: any[] = []; + + // Prepare x axis + let { timeIndex } = getTimeField(data); + let xvals = data.fields[timeIndex!].values.toArray(); + + if (!isNaN(timeIndex!)) { + xvals = xvals.map(v => v / 1000); + } + + plotData.push(xvals); + + for (let i = 0; i < data.fields.length; i++) { + const field = data.fields[i]; + + // already handled time and we ignore non-numeric fields + if (i === timeIndex || field.type !== FieldType.number) { + continue; + } + + let values = field.values.toArray(); + + if (field.config.custom?.nullValues === 'asZero') { + values = values.map(v => (v === null ? 0 : v)); + } + + plotData.push(values); + } + + return plotData; +}; + +/** + * Based on two config objects indicates whether or not uPlot needs reinitialisation + * This COULD be done based on data frames, but keeping it this way for now as a simplification + */ +export const shouldReinitialisePlot = (prevConfig: uPlot.Options, config: uPlot.Options) => { + // reinitialise when number of series, scales or axes changes + if ( + prevConfig.series?.length !== config.series?.length || + prevConfig.axes?.length !== config.axes?.length || + prevConfig.scales?.length !== config.scales?.length + ) { + return true; + } + + let idx = 0; + + // reinitialise when any of the series config changes + if (config.series && prevConfig.series) { + for (const series of config.series) { + if (!isEqual(series, prevConfig.series[idx])) { + return true; + } + idx++; + } + } + + if (config.axes && prevConfig.axes) { + idx = 0; + for (const axis of config.axes) { + // Comparing axes config, skipping values property as it changes across config builds - probably need to be more clever + if (!isEqual(omit(axis, 'values'), omit(prevConfig.axes[idx], 'values'))) { + return true; + } + idx++; + } + } + + return false; +}; + +// Dev helpers +export const throttledLog = throttle((...t: any[]) => { + console.log(...t); +}, 500); + +export const pluginLog = (id: string, throttle = false, ...t: any[]) => { + if (process.env.NODE_ENV === 'production') { + return; + } + const fn = throttle ? throttledLog : console.log; + fn(`[Plugin: ${id}]: `, ...t); +}; diff --git a/packages/grafana-ui/src/utils/standardEditors.tsx b/packages/grafana-ui/src/utils/standardEditors.tsx index 8611e840849..4062fd32cc7 100644 --- a/packages/grafana-ui/src/utils/standardEditors.tsx +++ b/packages/grafana-ui/src/utils/standardEditors.tsx @@ -18,6 +18,7 @@ import { valueMappingsOverrideProcessor, ThresholdsMode, TimeZone, + FieldColor, } from '@grafana/data'; import { Switch } from '../components/Switch/Switch'; @@ -211,8 +212,8 @@ export const getStandardFieldConfigs = () => { // settings: { // placeholder: '-', // }, - // shouldApply: () => true, - // category: ['Color & thresholds'], + // shouldApply: field => field.type !== FieldType.time, + // category, // }; return [unit, min, max, decimals, displayName, noValue, thresholds, mappings, links]; @@ -285,7 +286,7 @@ export const getStandardOptionEditors = () => { editor: ValueMappingsValueEditor as any, }; - const color: StandardEditorsRegistryItem = { + const color: StandardEditorsRegistryItem = { id: 'color', name: 'Color', description: 'Allows color selection', @@ -323,9 +324,9 @@ export const getStandardOptionEditors = () => { mappings, thresholds, links, - color, statsPicker, strings, timeZone, + color, ]; }; diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx index e0ee678da32..b7ea9b45c3f 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx @@ -102,6 +102,7 @@ export class PanelEditorUnconnected extends PureComponent { }; onFieldConfigChange = (config: FieldConfigSource) => { + console.log(config); const { panel } = this.props; panel.updateFieldConfig({ diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 2c6e5b64db3..d9a53f95129 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -38,6 +38,7 @@ const azureMonitorPlugin = async () => import * as textPanel from 'app/plugins/panel/text/module'; import * as graph2Panel from 'app/plugins/panel/graph2/module'; +import * as graph3Panel from 'app/plugins/panel/graph3/module'; import * as graphPanel from 'app/plugins/panel/graph/module'; import * as dashListPanel from 'app/plugins/panel/dashlist/module'; import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module'; @@ -79,6 +80,7 @@ const builtInPlugins: any = { 'app/plugins/panel/text/module': textPanel, 'app/plugins/panel/graph2/module': graph2Panel, + 'app/plugins/panel/graph3/module': graph3Panel, 'app/plugins/panel/graph/module': graphPanel, 'app/plugins/panel/dashlist/module': dashListPanel, 'app/plugins/panel/pluginlist/module': pluginsListPanel, diff --git a/public/app/plugins/panel/graph3/GraphPanel.tsx b/public/app/plugins/panel/graph3/GraphPanel.tsx new file mode 100644 index 00000000000..e12c5e1261a --- /dev/null +++ b/public/app/plugins/panel/graph3/GraphPanel.tsx @@ -0,0 +1,82 @@ +import React, { useMemo } from 'react'; +import { + ContextMenuPlugin, + TooltipPlugin, + UPlotChart, + ZoomPlugin, + LegendPlugin, + Canvas, + LegendDisplayMode, +} from '@grafana/ui'; +import { PanelProps } from '@grafana/data'; +import { Options } from './types'; +import { alignAndSortDataFramesByFieldName } from './utils'; +import { VizLayout } from './VizLayout'; + +interface GraphPanelProps extends PanelProps {} + +const TIME_FIELD_NAME = 'Time'; + +export const GraphPanel: React.FC = ({ + data, + timeRange, + timeZone, + width, + height, + options, + onChangeTimeRange, +}) => { + const alignedData = useMemo(() => { + if (!data || !data.series?.length) { + return null; + } + return alignAndSortDataFramesByFieldName(data.series, TIME_FIELD_NAME); + }, [data]); + + if (!alignedData) { + return ( +
+

No data found in response

+
+ ); + } + + return ( + + {({ builder, getLayout }) => { + const layout = getLayout(); + // when all layout slots are ready we can calculate the canvas(actual viz) size + const canvasSize = layout.isReady + ? { + width: width - (layout.left.width + layout.right.width), + height: height - (layout.top.height + layout.bottom.height), + } + : { width: 0, height: 0 }; + + if (options.legend.isVisible) { + builder.addSlot( + options.legend.placement, + + ); + } else { + builder.clearSlot(options.legend.placement); + } + + return ( + + {builder.addSlot('canvas', ).render()} + + + + + {/* TODO: */} + {/**/} + + ); + }} + + ); +}; diff --git a/public/app/plugins/panel/graph3/LayoutBuilder.ts b/public/app/plugins/panel/graph3/LayoutBuilder.ts new file mode 100644 index 00000000000..ee1810cb2e0 --- /dev/null +++ b/public/app/plugins/panel/graph3/LayoutBuilder.ts @@ -0,0 +1,52 @@ +import React from 'react'; + +export interface LayoutRendererComponentProps { + slots: Partial>; + refs: Record void>; + width: number; + height: number; +} + +export type LayoutRendererComponent = React.ComponentType>; + +// Fluent API for defining and rendering layout +export class LayoutBuilder { + private layout: Partial> = {}; + + constructor( + private renderer: LayoutRendererComponent, + private refsMap: Record void>, + private width: number, + private height: number + ) {} + + getLayout() { + return this.layout; + } + addSlot(id: T, node: React.ReactNode) { + this.layout[id] = node; + return this; + } + + clearSlot(id: T) { + if (this.layout[id] && this.refsMap[id]) { + delete this.layout[id]; + + this.refsMap[id](null); + } + return this; + } + + render() { + if (!this.layout) { + return null; + } + + return React.createElement(this.renderer, { + slots: this.layout, + refs: this.refsMap, + width: this.width, + height: this.height, + }); + } +} diff --git a/public/app/plugins/panel/graph3/README.md b/public/app/plugins/panel/graph3/README.md new file mode 100644 index 00000000000..ef1933f44bc --- /dev/null +++ b/public/app/plugins/panel/graph3/README.md @@ -0,0 +1,5 @@ +# Graph3 Panel - Native Plugin + +This is a graph panel exprimenting with the charting library: +https://github.com/leeoniya/uPlot + diff --git a/public/app/plugins/panel/graph3/VizLayout.tsx b/public/app/plugins/panel/graph3/VizLayout.tsx new file mode 100644 index 00000000000..287c3e916c7 --- /dev/null +++ b/public/app/plugins/panel/graph3/VizLayout.tsx @@ -0,0 +1,221 @@ +import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { css } from 'emotion'; +import { useMeasure } from './useMeasure'; +import { LayoutBuilder, LayoutRendererComponent } from './LayoutBuilder'; +import { CustomScrollbar } from '@grafana/ui'; + +type UseMeasureRect = Pick; + +const RESET_DIMENSIONS: UseMeasureRect = { + x: 0, + y: 0, + height: 0, + width: 0, + top: 0, + bottom: 0, + left: 0, + right: 0, +}; + +const DEFAULT_VIZ_LAYOUT_STATE = { + isReady: false, + top: RESET_DIMENSIONS, + bottom: RESET_DIMENSIONS, + right: RESET_DIMENSIONS, + left: RESET_DIMENSIONS, + canvas: RESET_DIMENSIONS, +}; + +export type VizLayoutSlots = 'top' | 'bottom' | 'left' | 'right' | 'canvas'; + +export interface VizLayoutState extends Record { + isReady: boolean; +} + +interface VizLayoutAPI { + builder: LayoutBuilder; + getLayout: () => VizLayoutState; +} + +interface VizLayoutProps { + width: number; + height: number; + children: (api: VizLayoutAPI) => React.ReactNode; +} + +/** + * Graph viz layout. Consists of 5 slots: top(T), bottom(B), left(L), right(R), canvas: + * + * +-----------------------------------------------+ + * | T | + * ----|---------------------------------------|---- + * | | | | + * | | | | + * | L | CANVAS SLOT | R | + * | | | | + * | | | | + * ----|---------------------------------------|---- + * | B | + * +-----------------------------------------------+ + * + */ +const VizLayoutRenderer: LayoutRendererComponent = ({ slots, refs, width, height }) => { + return ( +
+ {slots.top && ( +
+ {slots.top} +
+ )} + + {(slots.left || slots.right || slots.canvas) && ( +
+ {slots.left && ( +
+ {slots.left} +
+ )} + {slots.canvas &&
{slots.canvas}
} + {slots.right && ( +
+ {slots.right} +
+ )} +
+ )} + {slots.bottom && ( +
+ {slots.bottom} +
+ )} +
+ ); +}; + +export const VizLayout: React.FC = ({ children, width, height }) => { + /** + * Layout slots refs & bboxes + * Refs are passed down to the renderer component by layout builder + * It's up to the renderer to assign refs to correct slots(which are underlying DOM elements) + * */ + const [bottomSlotRef, bottomSlotBBox] = useMeasure(); + const [topSlotRef, topSlotBBox] = useMeasure(); + const [leftSlotRef, leftSlotBBox] = useMeasure(); + const [rightSlotRef, rightSlotBBox] = useMeasure(); + const [canvasSlotRef, canvasSlotBBox] = useMeasure(); + + // public fluent API exposed via render prop to build the layout + const builder = useMemo( + () => + new LayoutBuilder( + VizLayoutRenderer, + { + top: topSlotRef, + bottom: bottomSlotRef, + left: leftSlotRef, + right: rightSlotRef, + canvas: canvasSlotRef, + }, + width, + height + ), + [bottomSlotBBox, topSlotBBox, leftSlotBBox, rightSlotBBox, width, height] + ); + + // memoized map of layout slot bboxes, used for exposing correct bboxes when the layout is ready + const bboxMap = useMemo( + () => ({ + top: topSlotBBox, + bottom: bottomSlotBBox, + left: leftSlotBBox, + right: rightSlotBBox, + canvas: canvasSlotBBox, + }), + [bottomSlotBBox, topSlotBBox, leftSlotBBox, rightSlotBBox] + ); + + const [dimensions, setDimensions] = useState(DEFAULT_VIZ_LAYOUT_STATE); + + // when DOM settles we set the layout to be ready to get measurements downstream + useLayoutEffect(() => { + // layout is ready by now + const currentLayout = builder.getLayout(); + + // map active layout slots to corresponding bboxes + let nextDimensions: Partial> = {}; + for (const key of Object.keys(currentLayout)) { + nextDimensions[key as VizLayoutSlots] = bboxMap[key as VizLayoutSlots]; + } + + const nextState = { + // first, reset all bboxes to defaults + ...DEFAULT_VIZ_LAYOUT_STATE, + // set layout to ready + isReady: true, + // update state with active slot bboxes + ...nextDimensions, + }; + + setDimensions(nextState); + }, [bottomSlotBBox, topSlotBBox, leftSlotBBox, rightSlotBBox, width, height]); + + // returns current state of the layout, bounding rects of all slots to be rendered + const getLayout = useCallback(() => { + return dimensions; + }, [dimensions]); + + return ( +
+ {children({ + builder: builder, + getLayout, + })} +
+ ); +}; diff --git a/public/app/plugins/panel/graph3/img/icn-graph-panel.svg b/public/app/plugins/panel/graph3/img/icn-graph-panel.svg new file mode 100644 index 00000000000..eefedfb679a --- /dev/null +++ b/public/app/plugins/panel/graph3/img/icn-graph-panel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/app/plugins/panel/graph3/module.tsx b/public/app/plugins/panel/graph3/module.tsx new file mode 100644 index 00000000000..685b6627904 --- /dev/null +++ b/public/app/plugins/panel/graph3/module.tsx @@ -0,0 +1,215 @@ +import { + FieldColor, + FieldColorMode, + identityOverrideProcessor, + PanelPlugin, + standardEditorsRegistry, +} from '@grafana/data'; +import { GraphCustomFieldConfig } from '@grafana/ui'; +import { GraphPanel } from './GraphPanel'; +import { Options } from './types'; + +export const plugin = new PanelPlugin(GraphPanel) + .useFieldConfig({ + useCustomConfig: builder => { + builder + // TODO: Until we fix standard color property let's do it the custom editor way + .addCustomEditor<{}, FieldColor>({ + path: 'line.color', + id: 'line.color', + name: 'Series color', + shouldApply: () => true, + settings: {}, + defaultValue: { mode: FieldColorMode.Fixed }, + editor: standardEditorsRegistry.get('color').editor as any, + override: standardEditorsRegistry.get('color').editor as any, + process: identityOverrideProcessor, + }) + .addBooleanSwitch({ + path: 'line.show', + name: 'Show lines', + description: '', + defaultValue: true, + }) + .addSelect({ + path: 'line.width', + name: 'Line width', + defaultValue: 1, + settings: { + options: [ + { value: 1, label: '1 • thin' }, + { value: 2, label: '2' }, + { value: 3, label: '3' }, + { value: 4, label: '4' }, + { value: 5, label: '5' }, + { value: 6, label: '6' }, + { value: 7, label: '7' }, + { value: 8, label: '8' }, + { value: 9, label: '9' }, + { value: 10, label: '10 • thick' }, + ], + }, + showIf: c => { + console.log(c); + return c.line.show; + }, + }) + .addBooleanSwitch({ + path: 'points.show', + name: 'Show points', + description: '', + defaultValue: false, + }) + .addSelect({ + path: 'points.radius', + name: 'Point radius', + defaultValue: 4, + settings: { + options: [ + { value: 1, label: '1 • thin' }, + { value: 2, label: '2' }, + { value: 3, label: '3' }, + { value: 4, label: '4' }, + { value: 5, label: '5' }, + { value: 6, label: '6' }, + { value: 7, label: '7' }, + { value: 8, label: '8' }, + { value: 9, label: '9' }, + { value: 10, label: '10 • thick' }, + ], + }, + showIf: c => c.points.show, + }) + .addBooleanSwitch({ + path: 'bars.show', + name: 'Show bars', + description: '', + defaultValue: false, + }) + .addSelect({ + path: 'fill.alpha', + name: 'Fill area opacity', + defaultValue: 0.1, + settings: { + options: [ + { value: 0, label: 'No Fill' }, + { value: 0.1, label: '10% • transparent' }, + { value: 0.2, label: '20%' }, + { value: 0.3, label: '30%' }, + { value: 0.4, label: '40% ' }, + { value: 0.5, label: '50%' }, + { value: 0.6, label: '60%' }, + { value: 0.7, label: '70%' }, + { value: 0.8, label: '80%' }, + { value: 0.9, label: '90%' }, + { value: 1, label: '100% • opaque' }, + ], + }, + }) + .addTextInput({ + path: 'axis.label', + name: 'Axis Label', + category: ['Axis'], + defaultValue: '', + settings: { + placeholder: 'Optional text', + }, + // no matter what the field type is + shouldApply: () => true, + }) + .addRadio({ + path: 'axis.side', + name: 'Y axis side', + category: ['Axis'], + defaultValue: 3, + settings: { + options: [ + { value: 3, label: 'Left' }, + { value: 1, label: 'Right' }, + ], + }, + }) + .addNumberInput({ + path: 'axis.width', + name: 'Y axis width', + category: ['Axis'], + defaultValue: 60, + settings: { + placeholder: '60', + }, + }) + .addBooleanSwitch({ + path: 'axis.grid', + name: 'Show axis grid', + category: ['Axis'], + description: '', + defaultValue: true, + }) + .addRadio({ + path: 'nullValues', + name: 'Display null values as', + description: '', + defaultValue: 'null', + settings: { + options: [ + { value: 'null', label: 'null' }, + { value: 'connected', label: 'Connected' }, + { value: 'asZero', label: 'Zero' }, + ], + }, + }); + }, + }) + .setPanelOptions(builder => { + builder + .addRadio({ + path: 'tooltipOptions.mode', + name: 'Tooltip mode', + description: '', + defaultValue: 'single', + settings: { + options: [ + { value: 'single', label: 'Single series' }, + { value: 'multi', label: 'All series' }, + { value: 'none', label: 'No tooltip' }, + ], + }, + }) + // .addBooleanSwitch({ + // path: 'graph.realTimeUpdates', + // name: 'Real time updates', + // description: 'continue to update the graph so the time axis matches the clock.', + // defaultValue: false, + // }) + .addBooleanSwitch({ + category: ['Legend'], + path: 'legend.isVisible', + name: 'Show legend', + description: '', + defaultValue: true, + }) + .addBooleanSwitch({ + category: ['Legend'], + path: 'legend.asTable', + name: 'Display legend as table', + description: '', + defaultValue: false, + showIf: c => c.legend.isVisible, + }) + .addRadio({ + category: ['Legend'], + path: 'legend.placement', + name: 'Legend placement', + description: '', + defaultValue: 'bottom', + settings: { + options: [ + { value: 'left', label: 'Left' }, + { value: 'top', label: 'Top' }, + { value: 'bottom', label: 'Bottom' }, + { value: 'right', label: 'Right' }, + ], + }, + showIf: c => c.legend.isVisible, + }); + }); diff --git a/public/app/plugins/panel/graph3/plugin.json b/public/app/plugins/panel/graph3/plugin.json new file mode 100644 index 00000000000..c87373497e9 --- /dev/null +++ b/public/app/plugins/panel/graph3/plugin.json @@ -0,0 +1,17 @@ +{ + "type": "panel", + "name": "uPlot graph", + "id": "graph3", + "state": "alpha", + + "info": { + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/icn-graph-panel.svg", + "large": "img/icn-graph-panel.svg" + } + } +} diff --git a/public/app/plugins/panel/graph3/types.ts b/public/app/plugins/panel/graph3/types.ts new file mode 100644 index 00000000000..0b7cc5b6890 --- /dev/null +++ b/public/app/plugins/panel/graph3/types.ts @@ -0,0 +1,25 @@ +import { LegendOptions, GraphTooltipOptions } from '@grafana/ui'; + +export type NullValuesMode = 'null' | 'connected' | 'asZero'; +export type LegendPlacement = 'top' | 'bottom' | 'left' | 'right'; + +export interface GraphOptions { + // Redraw as time passes + realTimeUpdates?: boolean; +} + +export interface Options { + graph: GraphOptions; + legend: Omit & + GraphLegendEditorLegendOptions & { + placement: LegendPlacement; + }; + tooltipOptions: GraphTooltipOptions; +} + +export interface GraphLegendEditorLegendOptions extends LegendOptions { + stats?: string[]; + decimals?: number; + sortBy?: string; + sortDesc?: boolean; +} diff --git a/public/app/plugins/panel/graph3/useMeasure.tsx b/public/app/plugins/panel/graph3/useMeasure.tsx new file mode 100644 index 00000000000..8672d022d3a --- /dev/null +++ b/public/app/plugins/panel/graph3/useMeasure.tsx @@ -0,0 +1,49 @@ +import { useState, useMemo } from 'react'; +import { useIsomorphicLayoutEffect } from 'react-use'; + +export type UseMeasureRect = Pick< + DOMRectReadOnly, + 'x' | 'y' | 'top' | 'left' | 'right' | 'bottom' | 'height' | 'width' +>; +export type UseMeasureRef = (element: E) => void; +export type UseMeasureResult = [UseMeasureRef, UseMeasureRect]; + +const defaultState: UseMeasureRect = { + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + left: 0, + bottom: 0, + right: 0, +}; + +export const useMeasure = (): UseMeasureResult => { + const [element, ref] = useState(null); + const [rect, setRect] = useState(defaultState); + + const observer = useMemo( + () => + new (window as any).ResizeObserver((entries: any) => { + if (entries[0]) { + const { x, y, width, height, top, left, bottom, right } = entries[0].contentRect; + setRect({ x, y, width, height, top, left, bottom, right }); + } + }), + [] + ); + + useIsomorphicLayoutEffect(() => { + if (!element) { + setRect(defaultState); + return; + } + observer.observe(element); + return () => { + observer.disconnect(); + }; + }, [element]); + + return [ref, rect]; +}; diff --git a/public/app/plugins/panel/graph3/utils.ts b/public/app/plugins/panel/graph3/utils.ts new file mode 100644 index 00000000000..af41232e73b --- /dev/null +++ b/public/app/plugins/panel/graph3/utils.ts @@ -0,0 +1,39 @@ +import { DataFrame, FieldType, getTimeField, sortDataFrame, transformDataFrame } from '@grafana/data'; + +// very time oriented for now +export const alignAndSortDataFramesByFieldName = (data: DataFrame[], fieldName: string) => { + // normalize time field names + // in each frame find first time field and rename it to unified name + for (let i = 0; i < data.length; i++) { + const series = data[i]; + for (let j = 0; j < series.fields.length; j++) { + const field = series.fields[j]; + if (field.type === FieldType.time) { + field.name = fieldName; + break; + } + } + } + + const dataFramesToPlot = data.filter(frame => { + let { timeIndex } = getTimeField(frame); + // filter out series without time index or if the time column is the only one (i.e. after transformations) + // won't live long as we gona move out from assuming x === time + return timeIndex !== undefined ? frame.fields.length > 1 : false; + }); + + // uPlot data needs to be aligned on x-axis (ref. https://github.com/leeoniya/uPlot/issues/108) + // For experimentation just assuming alignment on time field, needs to change + const aligned = transformDataFrame( + [ + { + id: 'seriesToColumns', + options: { byField: fieldName }, + }, + ], + dataFramesToPlot + )[0]; + + // need to be more "clever", not time only in the future! + return sortDataFrame(aligned, getTimeField(aligned).timeIndex!); +}; diff --git a/yarn.lock b/yarn.lock index cf5ebe2fc44..aba5fc551b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26315,6 +26315,11 @@ update-notifier@^2.5.0: semver-diff "^2.0.0" xdg-basedir "^3.0.0" +uplot@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.1.2.tgz#ccdbe0987e7615d197e1dba77946a1655a823c31" + integrity sha512-CpQmMdafoMRR+zRSpfpMXs5mKvqgYFakcCyt7nOfh+pPeZfbxNMcCq9JFeXJcKEaWjrR6JSIiEZ01A4iFHztTQ== + upper-case-first@^1.1.0, upper-case-first@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/upper-case-first/-/upper-case-first-1.1.2.tgz#5d79bedcff14419518fd2edb0a0507c9b6859115"