aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/nodes/DataVizBox/components/Histogram.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/views/nodes/DataVizBox/components/Histogram.tsx')
-rw-r--r--src/client/views/nodes/DataVizBox/components/Histogram.tsx523
1 files changed, 523 insertions, 0 deletions
diff --git a/src/client/views/nodes/DataVizBox/components/Histogram.tsx b/src/client/views/nodes/DataVizBox/components/Histogram.tsx
new file mode 100644
index 000000000..b3bdccbbb
--- /dev/null
+++ b/src/client/views/nodes/DataVizBox/components/Histogram.tsx
@@ -0,0 +1,523 @@
+import { observer } from 'mobx-react';
+import { Doc, StrListCast } from '../../../../../fields/Doc';
+import * as React from 'react';
+import * as d3 from 'd3';
+import { IReactionDisposer, action, computed, observable, reaction } from 'mobx';
+import { LinkManager } from '../../../../util/LinkManager';
+import { Cast, DocCast, StrCast } from '../../../../../fields/Types';
+import { PinProps, PresBox } from '../../trails';
+import { Docs } from '../../../../documents/Documents';
+import { List } from '../../../../../fields/List';
+import './Chart.scss';
+import { ColorPicker, EditableText, IconButton, Size, Type } from 'browndash-components';
+import { FaFillDrip } from 'react-icons/fa';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { listSpec } from '../../../../../fields/Schema';
+import { scaleCreatorNumerical, yAxisCreator } from '../utils/D3Utils';
+import { undoBatch, undoable } from '../../../../util/UndoManager';
+
+export interface HistogramProps {
+ rootDoc: Doc;
+ layoutDoc: Doc;
+ axes: string[];
+ pairs: { [key: string]: any }[];
+ width: number;
+ height: number;
+ dataDoc: Doc;
+ fieldKey: string;
+ margin: {
+ top: number;
+ right: number;
+ bottom: number;
+ left: number;
+ };
+}
+
+@observer
+export class Histogram extends React.Component<HistogramProps> {
+ private _disposers: { [key: string]: IReactionDisposer } = {};
+ private _histogramRef: React.RefObject<HTMLDivElement> = React.createRef();
+ private _histogramSvg: d3.Selection<SVGGElement, unknown, null, undefined> | undefined;
+ private numericalXData: boolean = false; // whether the data is organized by numbers rather than categoreis
+ private numericalYData: boolean = false; // whether the y axis is controlled by provided data rather than frequency
+ private maxBins = 15; // maximum number of bins that is readable on a normal sized doc
+ @observable _currSelected: any | undefined = undefined; // Object of selected bar
+ private curBarSelected: any = undefined; // histogram bin of selected bar
+ private selectedData: any = undefined; // Selection of selected bar
+ private hoverOverData: any = undefined; // Selection of bar being hovered over
+
+ // filters all data to just display selected data if brushed (created from an incoming link)
+ @computed get _histogramData() {
+ var guids = StrListCast(this.props.layoutDoc.dataViz_rowGuids);
+ if (this.props.axes.length < 1) return [];
+ if (this.props.axes.length < 2) {
+ var ax0 = this.props.axes[0];
+ if (/\d/.test(this.props.pairs[0][ax0])) {
+ this.numericalXData = true;
+ }
+ return this.props.pairs
+ ?.filter(pair => (!this.incomingLinks.length ? true : this.incomingLinks[0]!.dataViz_selectedRows && StrListCast(this.incomingLinks[0].dataViz_selectedRows).includes(guids[this.props.pairs.indexOf(pair)])))
+ .map(pair => ({ [ax0]: pair[this.props.axes[0]] }));
+ }
+ var ax0 = this.props.axes[0];
+ var ax1 = this.props.axes[1];
+ if (/\d/.test(this.props.pairs[0][ax0])) {
+ this.numericalXData = true;
+ }
+ if (/\d/.test(this.props.pairs[0][ax1])) {
+ this.numericalYData = true;
+ }
+ return this.props.pairs
+ ?.filter(pair => (!this.incomingLinks.length ? true : this.incomingLinks[0]!.dataViz_selectedRows && StrListCast(this.incomingLinks[0].dataViz_selectedRows).includes(guids[this.props.pairs.indexOf(pair)])))
+ .map(pair => ({ [ax0]: pair[this.props.axes[0]], [ax1]: pair[this.props.axes[1]] }));
+ }
+
+ @computed get defaultGraphTitle() {
+ var ax0 = this.props.axes[0];
+ var ax1 = this.props.axes.length > 1 ? this.props.axes[1] : undefined;
+ if (this.props.axes.length < 2 || !ax1 || !/\d/.test(this.props.pairs[0][ax1]) || !this.numericalYData) {
+ return ax0 + ' Histogram';
+ } else return ax0 + ' by ' + ax1 + ' Histogram';
+ }
+
+ @computed get incomingLinks() {
+ return LinkManager.Instance.getAllRelatedLinks(this.props.rootDoc) // out of all links
+ .filter(link => link.link_anchor_1 == this.props.rootDoc.draggedFrom) // get links where this chart doc is the target of the link
+ .map(link => DocCast(link.link_anchor_1)); // then return the source of the link
+ }
+
+ @computed get rangeVals(): { xMin?: number; xMax?: number; yMin?: number; yMax?: number } {
+ if (this.numericalXData) {
+ const data = this.data(this._histogramData);
+ return { xMin: Math.min.apply(null, data), xMax: Math.max.apply(null, data), yMin: 0, yMax: 0 };
+ }
+ return { xMin: 0, xMax: 0, yMin: 0, yMax: 0 };
+ }
+
+ componentWillUnmount() {
+ Array.from(Object.keys(this._disposers)).forEach(key => this._disposers[key]());
+ }
+ componentDidMount = () => {
+ this._disposers.chartData = reaction(
+ () => ({ dataSet: this._histogramData, w: this.width, h: this.height }),
+ ({ dataSet, w, h }) => {
+ if (dataSet!.length > 0) {
+ this.drawChart(dataSet, w, h);
+ }
+ },
+ { fireImmediately: true }
+ );
+ };
+
+ @action
+ restoreView = (data: Doc) => {};
+ // create a document anchor that stores whatever is needed to reconstruct the viewing state (selection,zoom,etc)
+ getAnchor = (pinProps?: PinProps) => {
+ const anchor = Docs.Create.ConfigDocument({
+ //
+ title: 'histogram doc selection' + this._currSelected,
+ });
+ PresBox.pinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: pinProps?.pinData }, this.props.rootDoc);
+ return anchor;
+ };
+
+ @computed get height() {
+ return this.props.height - this.props.margin.top - this.props.margin.bottom;
+ }
+
+ @computed get width() {
+ return this.props.width - this.props.margin.left - this.props.margin.right;
+ }
+
+ // cleans data by converting numerical data to numbers and taking out empty cells
+ data = (dataSet: any) => {
+ var validData = dataSet.filter((d: { [x: string]: unknown }) => {
+ var valid = true;
+ Object.keys(dataSet[0]).map(key => {
+ if (!d[key] || Number.isNaN(d[key])) valid = false;
+ });
+ return valid;
+ });
+ var field = dataSet[0] ? Object.keys(dataSet[0])[0] : undefined;
+ const data = validData.map((d: { [x: string]: any }) => {
+ if (this.numericalXData) {
+ return +d[field!].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '');
+ }
+ return d[field!];
+ });
+ return data;
+ };
+
+ // outlines the bar selected / hovered over
+ highlightSelectedBar = (changeSelectedVariables: boolean, svg: any, eachRectWidth: any, pointerX: any, xAxisTitle: any, yAxisTitle: any, histDataSet: any) => {
+ var sameAsCurrent: boolean;
+ var barCounter = -1;
+ const selected = svg.selectAll('.histogram-bar').filter((d: any) => {
+ barCounter++; // uses the order of bars and width of each bar to find which one the pointer is over
+ if (barCounter * eachRectWidth <= pointerX && pointerX <= (barCounter + 1) * eachRectWidth) {
+ var showSelected = this.numericalYData
+ ? this._histogramData.filter((data: { [x: string]: any }) => StrCast(data[xAxisTitle]).replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '') == d[0])[0]
+ : histDataSet.filter((data: { [x: string]: any }) => data[xAxisTitle].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '') == d[0])[0];
+ if (this.numericalXData) {
+ // calculating frequency
+ if (d[0] && d[1] && d[0] != d[1]) {
+ showSelected = { [xAxisTitle]: d3.min(d) + ' to ' + d3.max(d), frequency: d.length };
+ } else if (!this.numericalYData) showSelected = { [xAxisTitle]: showSelected[xAxisTitle], frequency: d.length };
+ }
+ if (changeSelectedVariables) {
+ // for when a bar is selected - not just hovered over
+ sameAsCurrent = this._currSelected ? showSelected[xAxisTitle] == this._currSelected![xAxisTitle] && showSelected[yAxisTitle] == this._currSelected![yAxisTitle] : false;
+ this._currSelected = sameAsCurrent ? undefined : showSelected;
+ this.selectedData = sameAsCurrent ? undefined : d;
+ } else this.hoverOverData = d;
+ return true;
+ }
+ return false;
+ });
+ if (changeSelectedVariables) {
+ if (sameAsCurrent!) this.curBarSelected = undefined;
+ else this.curBarSelected = selected;
+ }
+ };
+
+ // draws the histogram
+ drawChart = (dataSet: any, width: number, height: number) => {
+ d3.select(this._histogramRef.current).select('svg').remove();
+ d3.select(this._histogramRef.current).select('.tooltip').remove();
+
+ var data = this.data(dataSet);
+ var xAxisTitle = Object.keys(dataSet[0])[0];
+ var yAxisTitle = this.numericalYData ? Object.keys(dataSet[0])[1] : 'frequency';
+ let uniqueArr: unknown[] = [...new Set(data)];
+ var numBins = this.numericalXData && Number.isInteger(data[0]) ? this.rangeVals.xMax! - this.rangeVals.xMin! : uniqueArr.length;
+ var translateXAxis = !this.numericalXData || numBins < this.maxBins ? width / (numBins + 1) / 2 : 0;
+ if (numBins > this.maxBins) numBins = this.maxBins;
+ var startingPoint = this.numericalXData ? this.rangeVals.xMin! : 0;
+ var endingPoint = this.numericalXData ? this.rangeVals.xMax! : numBins;
+
+ // converts data into Objects
+ var histDataSet = dataSet.filter((d: { [x: string]: unknown }) => {
+ var valid = true;
+ Object.keys(dataSet[0]).map(key => {
+ if (!d[key] || Number.isNaN(d[key])) valid = false;
+ });
+ return valid;
+ });
+ if (!this.numericalXData) {
+ var histStringDataSet: { [x: string]: unknown }[] = [];
+ if (this.numericalYData) {
+ for (let i = 0; i < dataSet.length; i++) {
+ histStringDataSet.push({ [yAxisTitle]: dataSet[i][yAxisTitle], [xAxisTitle]: dataSet[i][xAxisTitle] });
+ }
+ } else {
+ for (let i = 0; i < uniqueArr.length; i++) {
+ histStringDataSet.push({ [yAxisTitle]: 0, [xAxisTitle]: uniqueArr[i] });
+ }
+ for (let i = 0; i < data.length; i++) {
+ let barData = histStringDataSet.filter(each => each[xAxisTitle] == data[i]);
+ histStringDataSet.filter(each => each[xAxisTitle] == data[i])[0][yAxisTitle] = Number(barData[0][yAxisTitle]) + 1;
+ }
+ }
+ histDataSet = histStringDataSet;
+ }
+
+ // initial graph and binning data for histogram
+ var svg = (this._histogramSvg = d3
+ .select(this._histogramRef.current)
+ .append('svg')
+ .attr('class', 'graph')
+ .attr('width', width + this.props.margin.right + this.props.margin.left)
+ .attr('height', height + this.props.margin.top + this.props.margin.bottom)
+ .append('g')
+ .attr('transform', 'translate(' + this.props.margin.left + ',' + this.props.margin.top + ')'));
+ var x = d3
+ .scaleLinear()
+ .domain(this.numericalXData ? [startingPoint!, endingPoint!] : [0, numBins])
+ .range([0, width]);
+ var histogram = d3
+ .histogram()
+ .value(function (d) {
+ return d;
+ })
+ .domain([startingPoint!, endingPoint!])
+ .thresholds(x.ticks(numBins));
+ var bins = histogram(data);
+ var eachRectWidth = width / bins.length;
+ var graphStartingPoint = bins[0].x1 && bins[1] ? bins[0].x1! - (bins[1].x1! - bins[1].x0!) : 0;
+ bins[0].x0 = graphStartingPoint;
+ x = x.domain([graphStartingPoint, endingPoint]).range([0, Number.isInteger(this.rangeVals.xMin!) ? width - eachRectWidth : width]);
+ var xAxis;
+
+ // more calculations based on bins
+ // x-axis
+ if (!this.numericalXData) {
+ // reorganize to match data if the data is strings rather than numbers
+ // uniqueArr.sort()
+ histDataSet.sort();
+ for (let i = 0; i < data.length; i++) {
+ var index = 0;
+ for (let j = 0; j < uniqueArr.length; j++) {
+ if (uniqueArr[j] == data[i]) {
+ index = j;
+ }
+ }
+ if (bins[index]) bins[index].push(data[i]);
+ }
+ bins.pop();
+ eachRectWidth = width / bins.length;
+ bins.forEach(d => (d.x0 = d.x0!));
+ xAxis = d3
+ .axisBottom(x)
+ .ticks(bins.length > 1 ? bins.length - 1 : 1)
+ .tickFormat(i => uniqueArr[i.valueOf()] as string)
+ .tickPadding(10);
+ x.range([0, width - eachRectWidth]);
+ x.domain([0, bins.length - 1]);
+ translateXAxis = eachRectWidth / 2;
+ } else {
+ var allSame = true;
+ for (var i = 0; i < bins.length; i++) {
+ if (bins[i] && bins[i][0]) {
+ var compare = bins[i][0];
+ for (let j = 1; j < bins[i].length; j++) {
+ if (bins[i][j] != compare) allSame = false;
+ }
+ }
+ }
+ if (allSame) {
+ translateXAxis = eachRectWidth / 2;
+ eachRectWidth = width / bins.length;
+ } else {
+ eachRectWidth = width / (bins.length + 1);
+ var tickDiff = bins.length >= 2 ? bins[bins.length - 2].x1! - bins[bins.length - 2].x0! : 0;
+ var curDomain = x.domain();
+ x.domain([curDomain[0], curDomain[0] + tickDiff * bins.length]);
+ }
+
+ xAxis = d3.axisBottom(x).ticks(bins.length - 1);
+ x.range([0, width - eachRectWidth]);
+ }
+ // y-axis
+ const maxFrequency = this.numericalYData
+ ? d3.max(histDataSet, function (d: any) {
+ return d[yAxisTitle] ? Number(d[yAxisTitle]!.replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '')) : 0;
+ })
+ : d3.max(bins, function (d) {
+ return d.length;
+ });
+ var y = d3.scaleLinear().range([height, 0]);
+ y.domain([0, +maxFrequency!]);
+ var yAxis = d3.axisLeft(y).ticks(maxFrequency!);
+ if (this.numericalYData) {
+ const yScale = scaleCreatorNumerical(0, Number(maxFrequency), height, 0);
+ yAxisCreator(svg.append('g'), width, yScale);
+ } else {
+ svg.append('g').call(yAxis);
+ }
+ svg.append('g')
+ .attr('transform', 'translate(' + translateXAxis + ', ' + height + ')')
+ .call(xAxis);
+
+ // click/hover
+ const onPointClick = action((e: any) => this.highlightSelectedBar(true, svg, eachRectWidth, d3.pointer(e)[0], xAxisTitle, yAxisTitle, histDataSet));
+ const onHover = action((e: any) => {
+ const selected = this.highlightSelectedBar(false, svg, eachRectWidth, d3.pointer(e)[0], xAxisTitle, yAxisTitle, histDataSet);
+ updateHighlights();
+ });
+ const mouseOut = action((e: any) => {
+ this.hoverOverData = undefined;
+ updateHighlights();
+ });
+ const updateHighlights = () => {
+ const hoverOverBar = this.hoverOverData;
+ const selectedData = this.selectedData;
+ svg.selectAll('rect').attr('class', function (d: any) {
+ return (hoverOverBar && hoverOverBar[0] == d[0]) || (selectedData && selectedData[0] == d[0]) ? 'histogram-bar hover' : 'histogram-bar';
+ });
+ };
+ svg.on('click', onPointClick).on('mouseover', onHover).on('mouseout', mouseOut);
+
+ // axis titles
+ svg.append('text')
+ .attr('transform', 'translate(' + width / 2 + ' ,' + (height + 40) + ')')
+ .style('text-anchor', 'middle')
+ .text(xAxisTitle);
+ svg.append('text')
+ .attr('transform', 'rotate(-90)' + ' ' + 'translate( 0, ' + -10 + ')')
+ .attr('x', -(height / 2))
+ .attr('y', -20)
+ .style('text-anchor', 'middle')
+ .text(yAxisTitle);
+ d3.format('.0f');
+
+ // draw bars
+ var selected = this.selectedData;
+ svg.selectAll('rect')
+ .data(bins)
+ .enter()
+ .append('rect')
+ .attr(
+ 'transform',
+ this.numericalYData
+ ? function (d) {
+ var eachData = histDataSet.filter((data: { [x: string]: number }) => {
+ return data[xAxisTitle] == d[0];
+ });
+ var length = eachData.length ? eachData[0][yAxisTitle].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '') : 0;
+ return 'translate(' + x(d.x0!) + ',' + y(length) + ')';
+ }
+ : function (d) {
+ return 'translate(' + x(d.x0!) + ',' + y(d.length) + ')';
+ }
+ )
+ .attr(
+ 'height',
+ this.numericalYData
+ ? function (d) {
+ var eachData = histDataSet.filter((data: { [x: string]: number }) => {
+ return data[xAxisTitle] == d[0];
+ });
+ var length = eachData.length ? eachData[0][yAxisTitle].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, '') : 0;
+ return height - y(length);
+ }
+ : function (d) {
+ return height - y(d.length);
+ }
+ )
+ .attr('width', eachRectWidth)
+ .attr(
+ 'class',
+ selected
+ ? function (d) {
+ return selected && selected[0] == d[0] ? 'histogram-bar hover' : 'histogram-bar';
+ }
+ : function (d) {
+ return 'histogram-bar';
+ }
+ )
+ .attr('fill', d => {
+ var barColor;
+ var barColors = StrListCast(this.props.layoutDoc.histogramBarColors).map(each => each.split('::'));
+ barColors.map(each => {
+ if (d[0] && d[0].toString() && each[0] == d[0].toString()) barColor = each[1];
+ else {
+ var range = StrCast(each[0]).split(' to ');
+ if (Number(range[0]) <= d[0] && d[0] <= Number(range[1])) barColor = each[1];
+ }
+ });
+ return barColor ? StrCast(barColor) : StrCast(this.props.layoutDoc.defaultHistogramColor);
+ });
+ };
+
+ @action changeSelectedColor = (color: string) => {
+ this.curBarSelected.attr('fill', color);
+ var barName = StrCast(this._currSelected[this.props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, ''));
+
+ const barColors = Cast(this.props.layoutDoc.histogramBarColors, listSpec('string'), null);
+ barColors.map(each => {
+ if (each.split('::')[0] == barName) barColors.splice(barColors.indexOf(each), 1);
+ });
+ barColors.push(StrCast(barName + '::' + color));
+ };
+
+ @action eraseSelectedColor = () => {
+ this.curBarSelected.attr('fill', this.props.layoutDoc.defaultHistogramColor);
+ var barName = StrCast(this._currSelected[this.props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, ''));
+
+ const barColors = Cast(this.props.layoutDoc.histogramBarColors, listSpec('string'), null);
+ barColors.map(each => {
+ if (each.split('::')[0] == barName) barColors.splice(barColors.indexOf(each), 1);
+ });
+ };
+
+ render() {
+ this._histogramData;
+ var curSelectedBarName = '';
+ var titleAccessor: any = '';
+ if (this.props.axes.length == 2) titleAccessor = 'dataViz_title_histogram_' + this.props.axes[0] + '-' + this.props.axes[1];
+ else if (this.props.axes.length > 0) titleAccessor = 'dataViz_title_histogram_' + this.props.axes[0];
+ if (!this.props.layoutDoc[titleAccessor]) this.props.layoutDoc[titleAccessor] = this.defaultGraphTitle;
+ if (!this.props.layoutDoc.defaultHistogramColor) this.props.layoutDoc.defaultHistogramColor = '#69b3a2';
+ if (!this.props.layoutDoc.histogramBarColors) this.props.layoutDoc.histogramBarColors = new List<string>();
+ var selected: string;
+ if (this._currSelected) {
+ curSelectedBarName = StrCast(this._currSelected![this.props.axes[0]].replace(/\$/g, '').replace(/\%/g, '').replace(/\</g, ''));
+ selected = '{ ';
+ Object.keys(this._currSelected).map(key => {
+ key != '' ? (selected += key + ': ' + this._currSelected[key] + ', ') : '';
+ });
+ selected = selected.substring(0, selected.length - 2);
+ selected += ' }';
+ } else selected = 'none';
+ var selectedBarColor;
+ var barColors = StrListCast(this.props.layoutDoc.histogramBarColors).map(each => each.split('::'));
+ barColors.map(each => {
+ if (each[0] == curSelectedBarName!) selectedBarColor = each[1];
+ });
+
+ this.componentDidMount();
+
+ if (this._histogramData.length > 0 || (!this.incomingLinks || this.incomingLinks.length==0)) {
+ return this.props.axes.length >= 1 ? (
+ <div className="chart-container">
+ <div className="graph-title">
+ <EditableText
+ val={StrCast(this.props.layoutDoc[titleAccessor])}
+ setVal={undoable(
+ action(val => (this.props.layoutDoc[titleAccessor] = val as string)),
+ 'Change Graph Title'
+ )}
+ color={'black'}
+ size={Size.LARGE}
+ fillWidth
+ />
+ &nbsp; &nbsp;
+ <ColorPicker
+ tooltip={'Change Default Bar Color'}
+ type={Type.SEC}
+ icon={<FaFillDrip />}
+ selectedColor={StrCast(this.props.layoutDoc.defaultHistogramColor)}
+ setFinalColor={undoable(color => (this.props.layoutDoc.defaultHistogramColor = color), 'Change Default Bar Color')}
+ setSelectedColor={undoable(color => (this.props.layoutDoc.defaultHistogramColor = color), 'Change Default Bar Color')}
+ size={Size.XSMALL}
+ />
+ </div>
+ <div ref={this._histogramRef} />
+ {selected != 'none' ? (
+ <div className={'selected-data'}>
+ Selected: {selected}
+ &nbsp; &nbsp;
+ <ColorPicker
+ tooltip={'Change Bar Color'}
+ type={Type.SEC}
+ icon={<FaFillDrip />}
+ selectedColor={selectedBarColor ? selectedBarColor : this.curBarSelected.attr('fill')}
+ setFinalColor={undoable(color => this.changeSelectedColor(color), 'Change Selected Bar Color')}
+ setSelectedColor={undoable(color => this.changeSelectedColor(color), 'Change Selected Bar Color')}
+ size={Size.XSMALL}
+ />
+ &nbsp;
+ <IconButton
+ icon={<FontAwesomeIcon icon={'eraser'} />}
+ size={Size.XSMALL}
+ color={'black'}
+ type={Type.SEC}
+ tooltip={'Revert to the default bar color'}
+ onClick={undoable(
+ action(() => this.eraseSelectedColor()),
+ 'Change Selected Bar Color'
+ )}
+ />
+ </div>
+ ) : null}
+ </div>
+ ) : (
+ <span className="chart-container"> {'first use table view to select a column to graph'}</span>
+ );
+ } else
+ return (
+ // when it is a brushed table and the incoming table doesn't have any rows selected
+ <div className="chart-container">Selected rows of data from the incoming DataVizBox to display.</div>
+ );
+ }
+} \ No newline at end of file