Commit 917b5c5f by Leon Sorokin Committed by GitHub

[graph-ng] add temporal DataFrame alignment/outerJoin & move null-asZero pass inside (#29250)

[GraphNG] update uPlot, add temporal DataFrame alignment/outerJoin, move null-asZero pass inside, merge isGap updates into u.setData() calls.

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
parent 1076f475
......@@ -71,7 +71,7 @@
"react-transition-group": "4.4.1",
"slate": "0.47.8",
"tinycolor2": "1.4.1",
"uplot": "1.3.0"
"uplot": "1.4.4"
},
"devDependencies": {
"@rollup/plugin-commonjs": "11.0.2",
......
......@@ -7,9 +7,8 @@ import {
getFieldColorModeForField,
getFieldDisplayName,
getTimeField,
TIME_SERIES_TIME_FIELD_NAME,
} from '@grafana/data';
import { alignAndSortDataFramesByFieldName } from './utils';
import { mergeTimeSeriesData } from './utils';
import { UPlotChart } from '../uPlot/Plot';
import { PlotProps } from '../uPlot/types';
import { AxisPlacement, getUPlotSideFromAxis, GraphFieldConfig, GraphMode, PointMode } from '../uPlot/config';
......@@ -43,11 +42,11 @@ export const GraphNG: React.FC<GraphNGProps> = ({
...plotProps
}) => {
const theme = useTheme();
const alignedData = useMemo(() => alignAndSortDataFramesByFieldName(data, TIME_SERIES_TIME_FIELD_NAME), [data]);
const alignedFrameWithGapTest = useMemo(() => mergeTimeSeriesData(data), [data]);
const legendItemsRef = useRef<LegendItem[]>([]);
const hasLegend = legend && legend.displayMode !== LegendDisplayMode.Hidden;
if (!alignedData) {
if (alignedFrameWithGapTest == null) {
return (
<div className="panel-empty">
<p>No data found in response</p>
......@@ -55,10 +54,12 @@ export const GraphNG: React.FC<GraphNGProps> = ({
);
}
const alignedFrame = alignedFrameWithGapTest.frame;
const configBuilder = useMemo(() => {
const builder = new UPlotConfigBuilder();
let { timeIndex } = getTimeField(alignedData);
let { timeIndex } = getTimeField(alignedFrame);
if (timeIndex === undefined) {
timeIndex = 0; // assuming first field represents x-domain
......@@ -85,8 +86,8 @@ export const GraphNG: React.FC<GraphNGProps> = ({
let hasLeftAxis = false;
let hasYAxis = false;
for (let i = 0; i < alignedData.fields.length; i++) {
const field = alignedData.fields[i];
for (let i = 0; i < alignedFrame.fields.length; i++) {
const field = alignedFrame.fields[i];
const config = field.config as FieldConfig<GraphFieldConfig>;
const customConfig = config.custom || defaultConfig;
......@@ -137,7 +138,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
if (hasLegend) {
legendItems.push({
color: seriesColor,
label: getFieldDisplayName(field, alignedData),
label: getFieldDisplayName(field, alignedFrame),
yAxis: side === AxisPlacement.Right ? 3 : 1,
});
}
......@@ -147,7 +148,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
legendItemsRef.current = legendItems;
return builder;
}, [alignedData, hasLegend]);
}, [alignedFrameWithGapTest, hasLegend]);
let legendElement: React.ReactElement | undefined;
......@@ -163,7 +164,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
<VizLayout width={width} height={height} legend={legendElement}>
{(vizWidth: number, vizHeight: number) => (
<UPlotChart
data={alignedData}
data={alignedFrameWithGapTest}
config={configBuilder}
width={vizWidth}
height={vizHeight}
......
import { DataFrame, FieldType, getTimeField, outerJoinDataFrames, sortDataFrame } from '@grafana/data';
import {
DataFrame,
FieldType,
getTimeField,
ArrayVector,
NullValueMode,
getFieldDisplayName,
Field,
} from '@grafana/data';
import { AlignedFrameWithGapTest } from '../uPlot/types';
import uPlot, { AlignedData, AlignedDataWithGapTest } from 'uplot';
// very time oriented for now
export const alignAndSortDataFramesByFieldName = (data: DataFrame[], fieldName: string): DataFrame | null => {
if (!data.length) {
/**
* Returns a single DataFrame with:
* - A shared time column
* - only numeric fields
*
* The input expects all frames to have a time field with values in ascending order
*
* @alpha
*/
export function mergeTimeSeriesData(frames: DataFrame[]): AlignedFrameWithGapTest | null {
const valuesFromFrames: AlignedData[] = [];
const sourceFields: Field[] = [];
for (const frame of frames) {
const { timeField } = getTimeField(frame);
if (!timeField) {
continue;
}
const alignedData: AlignedData = [
timeField.values.toArray(), // The x axis (time)
];
// find numeric fields
for (const field of frame.fields) {
if (field.type !== FieldType.number) {
continue;
}
let values = field.values.toArray();
if (field.config.nullValueMode === NullValueMode.AsZero) {
values = values.map(v => (v === null ? 0 : v));
}
alignedData.push(values);
// Add the first time field
if (sourceFields.length < 1) {
sourceFields.push(timeField);
}
// This will cache an appropriate field name in the field state
getFieldDisplayName(field, frame, frames);
sourceFields.push(field);
}
// Timeseries has tima and at least one number
if (alignedData.length > 1) {
valuesFromFrames.push(alignedData);
}
}
if (valuesFromFrames.length === 0) {
return null;
}
// 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;
// do the actual alignment (outerJoin on the first arrays)
const { data: alignedData, isGap } = outerJoinValues(valuesFromFrames);
if (alignedData!.length !== sourceFields.length) {
throw new Error('outerJoinValues lost a field?');
}
// Replace the values from the outer-join field
return {
frame: {
length: alignedData![0].length,
fields: alignedData!.map((vals, idx) => ({
...sourceFields[idx],
values: new ArrayVector(vals),
})),
},
isGap,
};
}
export function outerJoinValues(tables: AlignedData[]): AlignedDataWithGapTest {
if (tables.length === 1) {
return {
data: tables[0],
isGap: () => true,
};
}
let xVals: Set<number> = new Set();
let xNulls: Array<Set<number>> = [new Set()];
for (const t of tables) {
let xs = t[0];
let len = xs.length;
let nulls: Set<number> = new Set();
for (let i = 0; i < len; i++) {
xVals.add(xs[i]);
}
for (let j = 1; j < t.length; j++) {
let ys = t[j];
for (let i = 0; i < len; i++) {
if (ys[i] == null) {
nulls.add(xs[i]);
}
}
}
xNulls.push(nulls);
}
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;
});
let data: AlignedData = [Array.from(xVals).sort((a, b) => a - b)];
let alignedLen = data[0].length;
let xIdxs = new Map();
for (let i = 0; i < alignedLen; i++) {
xIdxs.set(data[0][i], i);
}
for (const t of tables) {
let xs = t[0];
for (let j = 1; j < t.length; j++) {
let ys = t[j];
let yVals = Array(alignedLen).fill(null);
for (let i = 0; i < ys.length; i++) {
yVals[xIdxs.get(xs[i])] = ys[i];
}
data.push(yVals);
}
}
const aligned = outerJoinDataFrames(dataFramesToPlot, { byField: fieldName })[0];
return sortDataFrame(aligned, getTimeField(aligned).timeIndex!);
};
return {
data: data,
isGap(u: uPlot, seriesIdx: number, dataIdx: number) {
// u.data has to be AlignedDate
let xVal = u.data[0][dataIdx];
return xNulls[seriesIdx].has(xVal!);
},
};
}
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { usePrevious } from 'react-use';
import uPlot from 'uplot';
import uPlot, { Options, AlignedData, AlignedDataWithGapTest } from 'uplot';
import { buildPlotContext, PlotContext } from './context';
import { pluginLog, preparePlotData, shouldInitialisePlot } from './utils';
import { pluginLog, shouldInitialisePlot } from './utils';
import { usePlotConfig } from './hooks';
import { PlotProps } from './types';
......@@ -12,7 +12,7 @@ import { PlotProps } from './types';
export const UPlotChart: React.FC<PlotProps> = props => {
const canvasRef = useRef<HTMLDivElement>(null);
const [plotInstance, setPlotInstance] = useState<uPlot>();
const plotData = useRef<uPlot.AlignedData>();
const plotData = useRef<AlignedDataWithGapTest>();
// uPlot config API
const { currentConfig, registerPlugin } = usePlotConfig(props.width, props.height, props.timeZone, props.config);
......@@ -33,11 +33,12 @@ export const UPlotChart: React.FC<PlotProps> = props => {
return;
}
pluginLog('uPlot core', false, 'updating plot data(throttled log!)', plotData.current);
// If config hasn't changed just update uPlot's data
plotInstance.setData(plotData.current);
if (props.onDataUpdate) {
props.onDataUpdate(plotData.current);
props.onDataUpdate(plotData.current.data!);
}
}, [plotInstance, props.onDataUpdate]);
......@@ -50,7 +51,10 @@ export const UPlotChart: React.FC<PlotProps> = props => {
}, [plotInstance]);
useLayoutEffect(() => {
plotData.current = preparePlotData(props.data);
plotData.current = {
data: props.data.frame.fields.map(f => f.values.toArray()) as AlignedData,
isGap: props.data.isGap,
};
}, [props.data]);
// Decides if plot should update data or re-initialise
......@@ -62,7 +66,7 @@ export const UPlotChart: React.FC<PlotProps> = props => {
// Do nothing if there is data vs series config mismatch. This may happen when the data was updated and made this
// effect fire before the config update triggered the effect.
if (currentConfig.series.length !== plotData.current.length) {
if (currentConfig.series.length !== plotData.current.data!.length) {
return;
}
......@@ -70,7 +74,7 @@ export const UPlotChart: React.FC<PlotProps> = props => {
if (!canvasRef.current) {
throw new Error('Missing Canvas component as a child of the plot.');
}
const instance = initPlot(plotData.current, currentConfig, canvasRef.current);
const instance = initPlot(plotData.current.data!, currentConfig, canvasRef.current);
if (props.onPlotInit) {
props.onPlotInit();
......@@ -106,7 +110,7 @@ export const UPlotChart: React.FC<PlotProps> = props => {
};
// Main function initialising uPlot. If final config is not settled it will do nothing
function initPlot(data: uPlot.AlignedData, config: uPlot.Options, ref: HTMLDivElement) {
function initPlot(data: AlignedData, config: Options, ref: HTMLDivElement) {
pluginLog('uPlot core', false, 'initialized with', data, config);
return new uPlot(config, data, ref);
}
import { dateTimeFormat, GrafanaTheme, systemDateFormats, TimeZone } from '@grafana/data';
import uPlot from 'uplot';
import { AxisSide, PlotConfigBuilder } from '../types';
import uPlot, { Axis } from 'uplot';
import { PlotConfigBuilder } from '../types';
import { measureText } from '../../../utils/measureText';
export interface AxisProps {
......@@ -10,7 +10,7 @@ export interface AxisProps {
stroke?: string;
show?: boolean;
size?: number;
side?: AxisSide;
side?: Axis.Side;
grid?: boolean;
formatValue?: (v: any) => string;
values?: any;
......@@ -18,8 +18,8 @@ export interface AxisProps {
timeZone?: TimeZone;
}
export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, uPlot.Axis> {
getConfig(): uPlot.Axis {
export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
getConfig(): Axis {
const {
scaleKey,
label,
......@@ -35,7 +35,7 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, uPlot.Axis> {
const stroke = this.props.stroke || theme.colors.text;
const gridColor = theme.isDark ? theme.palette.gray25 : theme.palette.gray90;
let config: uPlot.Axis = {
let config: Axis = {
scale: scaleKey,
label,
show,
......
// TODO: migrate tests below to the builder
import { UPlotConfigBuilder } from './UPlotConfigBuilder';
import { AxisSide } from '../types';
import { GrafanaTheme } from '@grafana/data';
import { expect } from '../../../../../../public/test/lib/common';
......@@ -56,7 +55,7 @@ describe('UPlotConfigBuilder', () => {
scaleKey: 'scale-x',
label: 'test label',
timeZone: 'browser',
side: AxisSide.Bottom,
side: 2,
isTime: false,
formatValue: () => 'test value',
grid: false,
......
import uPlot from 'uplot';
import { Scale } from 'uplot';
import { PlotConfigBuilder } from '../types';
export interface ScaleProps {
......@@ -6,7 +6,7 @@ export interface ScaleProps {
isTime?: boolean;
}
export class UPlotScaleBuilder extends PlotConfigBuilder<ScaleProps, uPlot.Scale> {
export class UPlotScaleBuilder extends PlotConfigBuilder<ScaleProps, Scale> {
getConfig() {
const { isTime, scaleKey } = this.props;
return {
......
import tinycolor from 'tinycolor2';
import uPlot from 'uplot';
import { Series } from 'uplot';
import { PlotConfigBuilder } from '../types';
export interface SeriesProps {
......@@ -15,7 +15,7 @@ export interface SeriesProps {
fillColor?: string;
}
export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, uPlot.Series> {
export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
getConfig() {
const { line, lineColor, lineWidth, points, pointColor, pointSize, fillColor, fillOpacity, scaleKey } = this.props;
......
import React, { useCallback, useContext } from 'react';
import uPlot from 'uplot';
import { PlotPlugin } from './types';
import uPlot, { Series } from 'uplot';
import { PlotPlugin, AlignedFrameWithGapTest } from './types';
import { DataFrame, Field, FieldConfig } from '@grafana/data';
interface PlotCanvasContextType {
......@@ -23,10 +23,10 @@ interface PlotPluginsContextType {
interface PlotContextType extends PlotPluginsContextType {
isPlotReady: boolean;
getPlotInstance: () => uPlot;
getSeries: () => uPlot.Series[];
getSeries: () => Series[];
getCanvas: () => PlotCanvasContextType;
canvasRef: any;
data: DataFrame;
data: AlignedFrameWithGapTest;
}
export const PlotContext = React.createContext<PlotContextType>({} as PlotContextType);
......@@ -76,7 +76,7 @@ export const usePlotData = (): PlotDataAPI => {
if (!ctx) {
throwWhenNoContext('usePlotData');
}
return ctx!.data.fields[idx];
return ctx!.data.frame.fields[idx];
},
[ctx]
);
......@@ -109,7 +109,7 @@ export const usePlotData = (): PlotDataAPI => {
}
// 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);
return ctx!.data.frame.fields.slice(1);
}, [ctx]);
if (!ctx) {
......@@ -117,7 +117,7 @@ export const usePlotData = (): PlotDataAPI => {
}
return {
data: ctx.data,
data: ctx.data.frame,
getField,
getFieldValue,
getFieldConfig,
......@@ -129,7 +129,7 @@ export const usePlotData = (): PlotDataAPI => {
export const buildPlotContext = (
isPlotReady: boolean,
canvasRef: any,
data: DataFrame,
data: AlignedFrameWithGapTest,
registerPlugin: any,
getPlotInstance: () => uPlot
): PlotContextType => {
......
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PlotPlugin } from './types';
import { pluginLog } from './utils';
import uPlot from 'uplot';
import uPlot, { Options } from 'uplot';
import { getTimeZoneInfo, TimeZone } from '@grafana/data';
import { usePlotPluginContext } from './context';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
......@@ -106,7 +106,7 @@ export const DEFAULT_PLOT_CONFIG = {
export const usePlotConfig = (width: number, height: number, timeZone: TimeZone, configBuilder: UPlotConfigBuilder) => {
const { arePluginsReady, plugins, registerPlugin } = usePlotPlugins();
const [currentConfig, setCurrentConfig] = useState<uPlot.Options>();
const [currentConfig, setCurrentConfig] = useState<Options>();
const tzDate = useMemo(() => {
let fmt = undefined;
......
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
};
}
import React from 'react';
import uPlot from 'uplot';
import uPlot, { Options, AlignedData, Series, Hooks } from 'uplot';
import { DataFrame, TimeRange, TimeZone } from '@grafana/data';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
export type PlotSeriesConfig = Pick<uPlot.Options, 'series' | 'scales' | 'axes'>;
export type PlotSeriesConfig = Pick<Options, 'series' | 'scales' | 'axes'>;
export type PlotPlugin = {
id: string;
/** can mutate provided opts as necessary */
opts?: (self: uPlot, opts: uPlot.Options) => void;
hooks: uPlot.PluginHooks;
opts?: (self: uPlot, opts: Options) => void;
hooks: Hooks.ArraysOrFuncs;
};
export interface PlotPluginProps {
......@@ -16,7 +16,7 @@ export interface PlotPluginProps {
}
export interface PlotProps {
data: DataFrame;
data: AlignedFrameWithGapTest;
timeRange: TimeRange;
timeZone: TimeZone;
width: number;
......@@ -24,7 +24,7 @@ export interface PlotProps {
config: UPlotConfigBuilder;
children?: React.ReactElement[];
/** Callback performed when uPlot data is updated */
onDataUpdate?: (data: uPlot.AlignedData) => {};
onDataUpdate?: (data: AlignedData) => {};
/** Callback performed when uPlot is (re)initialized */
onPlotInit?: () => {};
}
......@@ -34,9 +34,7 @@ export abstract class PlotConfigBuilder<P, T> {
abstract getConfig(): T;
}
export enum AxisSide {
Top, // 0
Right, // 1
Bottom, // 2
Left, // 3
export interface AlignedFrameWithGapTest {
frame: DataFrame;
isGap: Series.isGap;
}
import throttle from 'lodash/throttle';
import isEqual from 'lodash/isEqual';
import omit from 'lodash/omit';
import { DataFrame, FieldType, getTimeField, rangeUtil, RawTimeRange } from '@grafana/data';
import uPlot from 'uplot';
import { rangeUtil, RawTimeRange } from '@grafana/data';
import { Options } from 'uplot';
import { PlotPlugin, PlotProps } from './types';
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;
......@@ -16,7 +16,7 @@ export function rangeToMinMax(timeRange: RawTimeRange): [number, number] {
return [v.from.valueOf() / 1000, v.to.valueOf() / 1000];
}
export const buildPlotConfig = (props: PlotProps, plugins: Record<string, PlotPlugin>): uPlot.Options => {
export const buildPlotConfig = (props: PlotProps, plugins: Record<string, PlotPlugin>): Options => {
return {
width: props.width,
height: props.height,
......@@ -38,40 +38,7 @@ export const buildPlotConfig = (props: PlotProps, plugins: Record<string, PlotPl
} as any;
};
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;
};
const isPlottingTime = (config: uPlot.Options) => {
const isPlottingTime = (config: Options) => {
let isTimeSeries = false;
if (!config.scales) {
......@@ -93,7 +60,7 @@ const isPlottingTime = (config: uPlot.Options) => {
* 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 shouldInitialisePlot = (prevConfig?: uPlot.Options, config?: uPlot.Options) => {
export const shouldInitialisePlot = (prevConfig?: Options, config?: Options) => {
if (!config && !prevConfig) {
return false;
}
......
......@@ -26919,10 +26919,10 @@ update-notifier@^2.5.0:
semver-diff "^2.0.0"
xdg-basedir "^3.0.0"
uplot@1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.3.0.tgz#c805e632c9dc0a2f47041fa0431996cbb42de83a"
integrity sha512-15EIwgOYdTeX6YXRJK6u3sq/gtFFa8ICdQROTeQBStmekhGgl8MixhL6pO66pmxPuzaJUrfIa+o5gvzttMF5rw==
uplot@1.4.4:
version "1.4.4"
resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.4.4.tgz#ab30da26b1e2d432df5bc43389e70399787f0448"
integrity sha512-vgV84+by3fGTU4bdpffSvA9FX8ide6MsmlBzOASPDdZCquXmCA+T2qodeNdnBen+7YOeqD9H91epVnF0dQgVKw==
upper-case-first@^1.1.0, upper-case-first@^1.1.2:
version "1.1.2"
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment