root/trunk/grails-app/controllers/dbnp/visualization/VisualizeController.groovy @ 2055

Revision 2055, 55.8 KB (checked in by tjeerd@…, 3 years ago)

VIS-31 and VIS-23, added some aggregation and other advanced settings. The option "no aggregation" is not present yet. There is also an error with the count-aggregation (see VIS-34)

Line 
1/**
2 * Visualize Controller
3 *
4 * This controller enables the user to visualize his data
5 *
6 * @author  robert@thehyve.nl
7 * @since       20110825
8 * @package     dbnp.visualization
9 *
10 * Revision information:
11 * $Rev$
12 * $Author$
13 * $Date$
14 */
15package dbnp.visualization
16
17import dbnp.studycapturing.*;
18import grails.converters.JSON
19
20import org.dbnp.gdt.*
21
22class VisualizeController {
23        def authenticationService
24        def moduleCommunicationService
25    def infoMessage = []
26    def offlineModules = []
27    def infoMessageOfflineModules = []
28    final int CATEGORICALDATA = 0
29    final int NUMERICALDATA = 1
30    final int RELTIME = 2
31    final int DATE = 3
32
33        /**
34         * Shows the visualization screen
35         */
36        def index = {
37                [ studies: Study.giveReadableStudies( authenticationService.getLoggedInUser() )]
38        }
39
40        def getStudies = {
41                def studies = Study.giveReadableStudies( authenticationService.getLoggedInUser() );
42        return sendResults(studies)
43        }
44
45        /**
46         * Based on the study id contained in the parameters given by the user, a list of 'fields' is returned. This list can be used to select what data should be visualized
47         * @return List containing fields
48     * @see parseGetDataParams
49         * @see getFields
50         */
51    def getFields = {
52                def input_object
53                def studies
54
55                try{
56                        input_object = parseGetDataParams();
57                } catch(Exception e) {
58                        log.error("VisualizationController: getFields: "+e)
59            return returnError(400, "An error occured while retrieving the user input.")
60                }
61
62        // Check to see if we have enough information
63        if(input_object==null || input_object?.studyIds==null){
64            setInfoMessage("Please select a study.")
65            return sendInfoMessage()
66        } else {
67            studies = input_object.studyIds[0]
68        }
69
70                def fields = [];
71
72        /*
73         Gather fields related to this study from GSCF.
74         This requires:
75         - a study.
76         - a category variable, e.g. "events".
77         - a type variable, either "domainfields" or "templatefields".
78         */
79        // TODO: Handle multiple studies
80        def study = Study.get(studies)
81
82        if(study!=null){
83            fields += getFields(study, "subjects", "domainfields")
84            fields += getFields(study, "subjects", "templatefields")
85            fields += getFields(study, "events", "domainfields")
86            fields += getFields(study, "events", "templatefields")
87            fields += getFields(study, "samplingEvents", "domainfields")
88            fields += getFields(study, "samplingEvents", "templatefields")
89            fields += getFields(study, "assays", "domainfields")
90            fields += getFields(study, "assays", "templatefields")
91            fields += getFields(study, "samples", "domainfields")
92            fields += getFields(study, "samples", "templatefields")
93
94            /*
95            Gather fields related to this study from modules.
96            This will use the getMeasurements RESTful service. That service returns measurement types, AKA features.
97            It does not actually return measurements (the getMeasurementData call does).
98            The getFields method (or rather, the getMeasurements service) requires one or more assays and will return all measurement
99            types related to these assays.
100            So, the required variables for such a call are:
101              - a source variable, which can be obtained from AssayModule.list() (use the 'name' field)
102              - an assay, which can be obtained with study.getAssays()
103             */
104            study.getAssays().each { assay ->
105                def list = []
106                if(!offlineModules.contains(assay.module.id)){
107                    list = getFields(assay.module.id, assay)
108                    if(list!=null){
109                        if(list.size()!=0){
110                            fields += list
111                        }
112                    }
113                }
114            }
115            offlineModules = []
116
117            // Make sure any informational messages regarding offline modules are submitted to the client
118            setInfoMessageOfflineModules()
119
120
121            // TODO: Maybe we should add study's own fields
122        } else {
123            log.error("VisualizationController: getFields: The requested study could not be found. Id: "+studies)
124            return returnError(404, "The requested study could not be found.")
125        }
126
127        fields.unique() // Todo: find out root cause of why some fields occur more than once
128        fields.sort { a, b ->
129            def sourceEquality = a.source.toString().toLowerCase().compareTo(b.source.toString().toLowerCase())
130            if( sourceEquality == 0 ) {
131                def categoryEquality = a.category.toString().toLowerCase().compareTo(b.category.toString().toLowerCase())
132                if( categoryEquality == 0 ){
133                    a.name.toString().toLowerCase().compareTo(b.name.toString().toLowerCase())
134                } else return categoryEquality
135            } else return sourceEquality
136        }
137                return sendResults(['studyIds': studies, 'fields': fields])
138        }
139
140        /**
141         * Based on the field ids contained in the parameters given by the user, a list of possible visualization types is returned. This list can be used to select how data should be visualized.
142         * @return List containing the possible visualization types, with each element containing
143     *          - a unique id
144     *          - a unique name
145     *         For example: ["id": "barchart", "name": "Barchart"]
146     * @see parseGetDataParams
147         * @see determineFieldType
148     * @see determineVisualizationTypes
149         */
150        def getVisualizationTypes = {
151        def inputData = parseGetDataParams();
152
153        if(inputData.columnIds == null || inputData.columnIds == [] || inputData.columnIds[0] == null || inputData.columnIds[0] == ""){
154            setInfoMessage("Please select a data source for the x-axis.")
155            return sendInfoMessage()
156        }
157        if(inputData.rowIds == null || inputData.rowIds == [] ||  inputData.rowIds[0] == null ||   inputData.rowIds[0] == ""){
158            setInfoMessage("Please select a data source for the y-axis.")
159            return sendInfoMessage()
160        }
161
162        // TODO: handle the case of multiple fields on an axis
163        // Determine data types
164        log.trace "Determining rowType: "+inputData.rowIds[0]
165        def rowType = determineFieldType(inputData.studyIds[0], inputData.rowIds[0])
166       
167                log.trace "Determining columnType: "+inputData.columnIds[0]
168        def columnType = determineFieldType(inputData.studyIds[0], inputData.columnIds[0])
169
170        // Determine possible visualization types
171        def types = determineVisualizationTypes(rowType, columnType)
172
173        log.trace  "types: "+types+", determined this based on "+rowType+" and "+columnType
174        return sendResults(['types':types,'rowIds':inputData.rowIds[0],'columnIds':inputData.columnIds[0]])
175        }
176
177    /**
178     * Gather fields related to this study from modules.
179        This will use the getMeasurements RESTful service. That service returns measurement types, AKA features.
180        getMeasurements does not actually return measurements (the getMeasurementData call does).
181     * @param source    The id of the module that is the source of the requested fields, as can be obtained from AssayModule.list() (use the 'id' field)
182     * @param assay     The assay that the source module and the requested fields belong to
183     * @return  A list of map objects, containing the following:
184     *           - a key 'id' with a value formatted by the createFieldId function
185     *           - a key 'source' with a value equal to the input parameter 'source'
186     *           - a key 'category' with a value equal to the 'name' field of the input paramater 'assay'
187     *           - a key 'name' with a value equal to the name of the field in question, as determined by the source value
188     */
189    def getFields(source, assay){
190        def fields = []
191        def callUrl = ""
192
193        // Making a different call for each assay
194        def urlVars = "assayToken="+assay.assayUUID
195        try {
196            callUrl = ""+assay.module.url + "/rest/getMeasurements/query?"+urlVars
197            def json = moduleCommunicationService.callModuleRestMethodJSON( assay.module.url /* consumer */, callUrl );
198            def collection = []
199            json.each{ jason ->
200                collection.add(jason)
201            }
202            // Formatting the data
203            collection.each { field ->
204                // For getting this field from this assay
205                fields << [ "id": createFieldId( id: field, name: field, source: assay.id, type: ""+assay.name), "source": source, "category": ""+assay.name, "name": field ]
206            }
207        } catch(Exception e){
208            //returnError(404, "An error occured while trying to collect field data from a module. Most likely, this module is offline.")
209            offlineModules.add(assay.module.id)
210            infoMessageOfflineModules.add(assay.module.name)
211            log.error("VisualizationController: getFields: "+e)
212        }
213
214        return fields
215    }
216
217    /**
218     * Gather fields related to this study from GSCF.
219     * @param study The study that is the source of the requested fields
220     * @param category  The domain that a field (a property in this case) belongs to, e.g. "subjects", "samplingEvents"
221     * @param type A string that indicates the type of field, either "domainfields" or "templatefields".
222     * @return A list of map objects, formatted by the formatGSCFFields function
223     */
224    def getFields(study, category, type){
225        // Collecting the data from it's source
226        def collection = []
227        def fields = []
228        def source = "GSCF"
229               
230                if( type == "domainfields" )
231                        collection = domainObjectCallback( category )?.giveDomainFields();
232                else
233                        collection = templateObjectCallback( category, study )?.template?.fields
234
235        collection?.unique()
236
237        // Formatting the data
238        fields += formatGSCFFields(type, collection, source, category)
239
240        // Here we will remove those fields, whose set of datapoints only contain null
241        def fieldsToBeRemoved = []
242        fields.each{ field ->
243            def fieldData = getFieldData( study, study.samples, field.id )
244            fieldData.removeAll([null])
245            if(fieldData==[]){
246                // Field only contained nulls, so don't show it as a visualization option
247                fieldsToBeRemoved << field
248            }
249        }
250        fields.removeAll(fieldsToBeRemoved)
251
252        return fields
253    }
254
255    /**
256     * Format the data contained in the input parameter 'collection' for use as so-called fields, that will be used by the user interface to allow the user to select data from GSCF for visualization
257     * @param type A string that indicates the type of field, either "domainfields" or "templatefields".
258     * @param collectionOfFields A collection of fields, which could also contain only one item
259     * @param source Likely to be "GSCF"
260     * @param category The domain that a field (a property in this case) belongs to, e.g. "subjects", "samplingEvents"
261     * @return A list containing list objects, containing the following:
262     *           - a key 'id' with a value formatted by the createFieldId function
263     *           - a key 'source' with a value equal to the input parameter 'source'
264     *           - a key 'category' with a value equal to the input parameter 'category'
265     *           - a key 'name' with a value equal to the name of the field in question, as determined by the source value
266     */
267    def formatGSCFFields(type, collectionOfFields, source, category){
268
269        if(collectionOfFields==null || collectionOfFields == []){
270            return []
271        }
272        def fields = []
273        if(collectionOfFields instanceof Collection){
274            // Apparently this field is actually a list of fields.
275            // We will call ourselves again with the list's elements as input.
276            // These list elements will themselves go through this check again, effectively flattening the original input
277            for(int i = 0; i < collectionOfFields.size(); i++){
278                fields += formatGSCFFields(type, collectionOfFields[i], source, category)
279            }
280            return fields
281        } else {
282            // This is a single field. Format it and return the result.
283            if(type=="domainfields"){
284                fields << [ "id": createFieldId( id: collectionOfFields.name, name: collectionOfFields.name, source: source, type: category ), "source": source, "category": category, "name": collectionOfFields.name ]
285            }
286            if(type=="templatefields"){
287                fields << [ "id": createFieldId( id: collectionOfFields.id, name: collectionOfFields.name, source: source, type: category ), "source": source, "category": category, "name": collectionOfFields.name ]
288            }
289            return fields
290        }
291    }
292
293        /**
294         * Retrieves data for the visualization itself.
295     * Returns, based on the field ids contained in the parameters given by the user, a map containing the actual data and instructions on how the data should be visualized.
296     * @return A map containing containing (at least, in the case of a barchart) the following:
297     *           - a key 'type' containing the type of chart that will be visualized
298     *           - a key 'xaxis' containing the title and unit that should be displayed for the x-axis
299     *           - a key 'yaxis' containing the title and unit that should be displayed for the y-axis*
300     *           - a key 'series' containing a list, that contains one or more maps, which contain the following:
301     *                - a key 'name', containing, for example, a feature name or field name
302     *                - a key 'y', containing a list of y-values
303     *                - a key 'error', containing a list of, for example, standard deviation or standard error of the mean values, each having the same index as the 'y'-values they are associated with
304         */
305        def getData = {
306                // Extract parameters
307                // TODO: handle erroneous input data
308                def inputData = parseGetDataParams();
309
310        if(inputData.columnIds == null || inputData.rowIds == null){
311            infoMessage = "Please select data sources for the y- and x-axes."
312            return sendInfoMessage()
313        }
314
315                // TODO: handle the case that we have multiple studies
316                def studyId = inputData.studyIds[ 0 ];
317                def study = Study.get( studyId as Integer );
318
319                // Find out what samples are involved
320                def samples = study.samples
321
322                // Retrieve the data for both axes for all samples
323                // TODO: handle the case of multiple fields on an axis
324                def fields = [ "x": inputData.columnIds[ 0 ], "y": inputData.rowIds[ 0 ] ];
325                def data = getAllFieldData( study, samples, fields );
326
327                // Group data based on the y-axis if categorical axis is selected
328        def groupedData
329        if(inputData.visualizationType=='horizontal_barchart'){
330            groupedData = groupFieldData( inputData.visualizationType, data, "y", "x" ); // Indicate non-standard axis ordering
331        } else {
332            groupedData = groupFieldData( inputData.visualizationType, data ); // Don't indicate axis ordering, standard <"x", "y"> will be used
333        }
334               
335        // Format data so it can be rendered as JSON
336        def returnData
337        if(inputData.visualizationType=='horizontal_barchart'){
338            def valueAxisType = determineFieldType(inputData.studyIds[0], inputData.rowIds[0], groupedData["x"])
339            def groupAxisType = determineFieldType(inputData.studyIds[0], inputData.columnIds[0], groupedData["y"])
340            returnData = formatData( inputData.visualizationType, groupedData, fields, groupAxisType, valueAxisType , "y", "x" ); // Indicate non-standard axis ordering
341        } else {
342            def valueAxisType = determineFieldType(inputData.studyIds[0], inputData.rowIds[0], groupedData["y"])
343            def groupAxisType = determineFieldType(inputData.studyIds[0], inputData.columnIds[0], groupedData["x"])
344            returnData = formatData( inputData.visualizationType, groupedData, fields, groupAxisType, valueAxisType ); // Don't indicate axis ordering, standard <"x", "y"> will be used
345        }
346        return sendResults(returnData)
347        }
348
349        /**
350         * Parses the parameters given by the user into a proper list
351         * @return Map with 4 keys:
352         *              studyIds:       list with studyIds selected
353         *              rowIds:         list with fieldIds selected for the rows
354         *              columnIds:      list with fieldIds selected for the columns
355         *              visualizationType:      String with the type of visualization required
356         * @see getFields
357         * @see getVisualizationTypes
358         */
359        def parseGetDataParams() {
360                def studyIds, rowIds, columnIds, visualizationType;
361               
362                studyIds = params.list( 'study' );
363                rowIds = params.list( 'rows' );
364                columnIds = params.list( 'columns' );
365                visualizationType = params.get( 'types')
366
367                return [ "studyIds" : studyIds, "rowIds": rowIds, "columnIds": columnIds, "visualizationType": visualizationType ];
368        }
369
370        /**
371         * Retrieve the field data for the selected fields
372         * @param study         Study for which the data should be retrieved
373         * @param samples       Samples for which the data should be retrieved
374         * @param fields        Map with key-value pairs determining the name and fieldId to retrieve data for. Example:
375         *                                              [ "x": "field-id-1", "y": "field-id-3" ]
376         * @return                      A map with the same keys as the input fields. The values in the map are lists of values of the
377         *                                      selected field for all samples. If a value could not be retrieved for a sample, null is returned. Example:
378         *                                              [ "x": [ 3, 6, null, 10 ], "y": [ "male", "male", "female", "female" ] ]
379         */
380        def getAllFieldData( study, samples, fields ) {
381                def fieldData = [:]
382                fields.each{ field ->
383                        fieldData[ field.key ] = getFieldData( study, samples, field.value );
384                }
385               
386                return fieldData;
387        }
388       
389        /**
390        * Retrieve the field data for the selected field
391        * @param study          Study for which the data should be retrieved
392        * @param samples        Samples for which the data should be retrieved
393        * @param fieldId        ID of the field to return data for
394        * @return                       A list of values of the selected field for all samples. If a value
395        *                                       could not be retrieved for a sample, null is returned. Examples:
396        *                                               [ 3, 6, null, 10 ] or [ "male", "male", "female", "female" ]
397        */
398        def getFieldData( study, samples, fieldId ) {
399                // Parse the fieldId as given by the user
400                def parsedField = parseFieldId( fieldId );
401               
402                def data = []
403               
404                if( parsedField.source == "GSCF" ) {
405                        // Retrieve data from GSCF itself
406                        def closure = valueCallback( parsedField.type )
407
408                        if( closure ) {
409                                samples.each { sample ->
410                                        // Retrieve the value for the selected field for this sample
411                                        def value = closure( sample, parsedField.name );
412                                       
413                                        if( value != null ) {
414                                                data << value;
415                                        } else {
416                                                // Return null if the value is not found
417                                                data << null
418                                        }
419                                }
420                        } else {
421                                // TODO: Handle error properly
422                                // Closure could not be retrieved, probably because the type is incorrect
423                                data = samples.collect { return null }
424                log.error("VisualizationController: getFieldData: Requested wrong field type: "+parsedField.type+". Parsed field: "+parsedField)
425                        }
426                } else {
427                        // Data must be retrieved from a module
428                        data = getModuleData( study, samples, parsedField.source, parsedField.name );
429                }
430               
431                return data
432        }
433       
434        /**
435         * Retrieve data for a given field from a data module
436         * @param study                 Study to retrieve data for
437         * @param samples               Samples to retrieve data for
438         * @param source_module Name of the module to retrieve data from
439         * @param fieldName             Name of the measurement type to retrieve (i.e. measurementToken)
440         * @return                              A list of values of the selected field for all samples. If a value
441         *                                              could not be retrieved for a sample, null is returned. Examples:
442         *                                                      [ 3, 6, null, 10 ] or [ "male", "male", "female", "female" ]
443         */
444        def getModuleData( study, samples, assay_id, fieldName ) {
445                def data = []
446               
447                // TODO: Handle values that should be retrieved from multiple assays
448        def assay = Assay.get(assay_id);
449
450        if( assay ) {
451            // Request for a particular assay and a particular feature
452            def urlVars = "assayToken=" + assay.assayUUID + "&measurementToken="+fieldName
453            urlVars += "&" + samples.collect { "sampleToken=" + it.sampleUUID }.join( "&" );
454
455            def callUrl
456            try {
457                callUrl = assay.module.url + "/rest/getMeasurementData"
458                def json = moduleCommunicationService.callModuleMethod( assay.module.url, callUrl, urlVars, "POST" );
459
460                if( json ) {
461                    // First element contains sampletokens
462                    // Second element contains the featurename
463                    // Third element contains the measurement value
464                    def sampleTokens = json[ 0 ]
465                    def measurements = json[ 2 ]
466
467                    // Loop through the samples
468                    samples.each { sample ->
469                        // Search for this sampletoken
470                        def sampleToken = sample.sampleUUID;
471                        def index = sampleTokens.findIndexOf { it == sampleToken }
472
473                        if( index > -1 ) {
474                            data << measurements[ index ];
475                        } else {
476                            data << null
477                        }
478                    }
479                } else {
480                    // TODO: handle error
481                    // Returns an empty list with as many elements as there are samples
482                    data = samples.collect { return null }
483                }
484
485            } catch(Exception e){
486                log.error("VisualizationController: getFields: "+e)
487                //return returnError(404, "An error occured while trying to collect data from a module. Most likely, this module is offline.")
488                return returnError(404, "Unfortunately, "+assay.module.name+" could not be reached. As a result, we cannot at this time visualize data contained in this module.")
489            }
490        } else {
491            // TODO: Handle error correctly
492            // Returns an empty list with as many elements as there are samples
493            data = samples.collect { return null }
494        }
495
496        //println "\t data request: "+data
497                return data
498        }
499
500        /**
501         * Group the field data on the values of the specified axis. For example, for a bar chart, the values
502         * on the x-axis should be grouped. Currently, the values for each group are averaged, and the standard
503         * error of the mean is returned in the 'error' property
504     * @param visualizationType Some types require a different formatting/grouping of the data, such as 'table'
505         * @param data          Data for both group- and value axis. The output of getAllFieldData fits this input
506         * @param groupAxis     Name of the axis to group on. Defaults to "x"
507         * @param valueAxis     Name of the axis where the values are. Defaults to "y"
508         * @param errorName     Key in the output map where 'error' values (SEM) are stored. Defaults to "error"
509         * @param unknownName   Name of the group for all null groups. Defaults to "unknown"
510         * @return                      A map with the keys 'groupAxis', 'valueAxis' and 'errorName'. The values in the map are lists of values of the
511         *                                      selected field for all groups. For example, if the input is
512         *                                              [ "x": [ "male", "male", "female", "female", null, "female" ], "y": [ 3, 6, null, 10, 4, 5 ] ]
513         *                                      the output will be:
514         *                                              [ "x": [ "male", "female", "unknown" ], "y": [ 4.5, 7.5, 4 ], "error": [ 1.5, 2.5, 0 ] ]
515         *
516         *                                      As you can see: null values in the valueAxis are ignored. Null values in the
517         *                                      group axis are combined into a 'unknown' category.
518         */
519        def groupFieldData( visualizationType, data, groupAxis = "x", valueAxis = "y", errorName = "error", unknownName = "unknown" ) {
520                // TODO: Give the user the possibility to change this value in the user interface
521                def showEmptyCategories = false;
522               
523                // Create a unique list of values in the groupAxis. First flatten the list, since it might be that a
524                // sample belongs to multiple groups. In that case, the group names should not be the lists, but the list
525                // elements. A few lines below, this case is handled again by checking whether a specific sample belongs
526                // to this group.
527                // After flattening, the list is uniqued. The closure makes sure that values with different classes are
528                // always treated as different items (e.g. "" should not equal 0, but it does if using the default comparator)
529                def groups = data[ groupAxis ]
530                                                .flatten()
531                                                .unique { it == null ? "null" : it.class.name + it.toString() }
532                                               
533                // Make sure the null category is last
534                groups = groups.findAll { it != null } + groups.findAll { it == null }
535               
536                // Generate the output object
537                def outputData = [:]
538                outputData[ valueAxis ] = [];
539                outputData[ errorName ] = [];
540                outputData[ groupAxis ] = [];
541               
542                // Loop through all groups, and gather the values for this group
543        // A visualization of type 'table' is a special case. There, the counts of two combinations of 'groupAxis'
544                // and 'valueAxis' items are computed
545        if( visualizationType=='table' ){
546            // For each 'valueAxis' item and 'groupAxis' item combination, count how often they appear together.
547            def counts = [:]
548                       
549            // The 'counts' list uses keys like this: ['item1':group, 'item2':value]
550            // The value will be an integer (the count)
551            data[ groupAxis ].eachWithIndex { group, index ->
552                def value =  data[ valueAxis ][index]
553                if(!counts.get(['item1':group, 'item2':value])){
554                    counts[['item1':group, 'item2':value]] = 1
555                } else {
556                    counts[['item1':group, 'item2':value]] = counts[['item1':group, 'item2':value]] + 1
557                }
558            }
559           
560                        def valueData =  data[ valueAxis ]
561                                        .flatten()
562                                        .unique { it == null ? "null" : it.class.name + it.toString() }
563                                                                                                       
564                        // Now we will first check whether any of the categories is empty. If some of the rows
565                        // or columns are empty, don't include them in the output
566                        if( !showEmptyCategories ) {
567                                groups.eachWithIndex { group, index ->
568                                        if( counts.findAll { it.key.item1 == group } )
569                                                outputData[groupAxis] << group
570                                }
571                               
572                                valueData.each { value ->
573                                        if( counts.findAll { it.key.item2 == value } )
574                                                outputData[valueAxis] << value
575                                }
576                        } else {
577                                outputData[groupAxis] = groups.collect { it != null ? it : unknownName }
578                                ouputData[valueAxis] = valueData
579                        }
580                                                                                                                               
581            // Because we are collecting counts, we do not set the 'errorName' item of the 'outputData' map.
582            // We do however set the 'data' map to contain the counts. We set it in such a manner that it has
583                        // a 'table' layout with respect to 'groupAxis' and 'valueAxis'.
584            def rows = []
585            outputData[groupAxis].each{ group ->
586                def row = []
587                outputData[valueAxis].each{ value ->
588                    row << counts[['item1':group, 'item2':value]]
589                }
590                while(row.contains(null)){
591                    row[row.indexOf(null)] = 0
592                } // 'null' should count as '0'. Items of count zero have never been added to the 'counts' list and as such will appear as a 'null' value.
593                if(row!=[]) rows << row
594            }
595            outputData['data']= rows
596                       
597                        // Convert groups to group names
598                        outputData[ groupAxis ] = outputData[ groupAxis ].collect { it != null ? it : unknownName }
599        } else {
600            groups.each { group ->
601                // Find the indices of the samples that belong to this group. if a sample belongs to multiple groups (i.e. if
602                // the samples groupAxis contains multiple values, is a collection), the value should be used in all groups.
603                def indices = data[ groupAxis ].findIndexValues { it instanceof Collection ? it.contains( group ) : it == group };
604                def values  = data[ valueAxis ][ indices ]
605
606                                // The computation for mean and error will return null if no (numerical) values are found
607                                // In that case, the user won't see this category
608                def dataForGroup = null;
609                switch( params.get( 'aggregation') ) {
610                                case "average":
611                        dataForGroup = computeMeanAndError( values );
612                        break;
613                    case "count":
614                        dataForGroup = computeCount( values );
615                        break;
616                    case "median":
617                        dataForGroup = computeMedian( values );
618                        break;
619                    case "none":
620                        // Currently disabled, create another function
621                        dataForGroup = computeMeanAndError( values );
622                        break;
623                    case "sum":
624                        dataForGroup = computeSum( values );
625                        break;
626                    default:
627                        // Default is "average"
628                        dataForGroup = computeMeanAndError( values );
629                }
630
631
632                                if( showEmptyCategories || dataForGroup.value != null ) {
633                                        // Gather names for the groups. Most of the times, the group names are just the names, only with
634                                        // a null value, the unknownName must be used
635                                        outputData[ groupAxis ] << ( group != null ? group : unknownName )
636                        outputData[ valueAxis ] << dataForGroup.value ?: 0
637                        outputData[ errorName ] << dataForGroup.error ?: 0
638                                }
639            }
640        }
641               
642                return outputData
643        }
644       
645        /**
646         * Formats the grouped data in such a way that the clientside visualization method
647         * can handle the data correctly.
648         * @param groupedData   Data that has been grouped using the groupFields method
649         * @param fields                Map with key-value pairs determining the name and fieldId to retrieve data for. Example:
650         *                                                      [ "x": "field-id-1", "y": "field-id-3" ]
651     * @param groupAxisType Integer, either CATEGORICAL or NUMERIACAL
652     * @param valueAxisType Integer, either CATEGORICAL or NUMERIACAL
653         * @param groupAxis             Name of the axis to with group data. Defaults to "x"
654         * @param valueAxis             Name of the axis where the values are stored. Defaults to "y"
655         * @param errorName             Key in the output map where 'error' values (SEM) are stored. Defaults to "error"         *
656         * @return                              A map like the following:
657         *
658                        {
659                                "type": "barchart",
660                                "xaxis": { "title": "quarter 2011", "unit": "" },
661                                "yaxis": { "title": "temperature", "unit": "degrees C" },
662                                "series": [
663                                        {
664                                                "name": "series name",
665                                                "y": [ 5.1, 3.1, 20.6, 15.4 ],
666                        "x": [ "Q1", "Q2", "Q3", "Q4" ],
667                                                "error": [ 0.5, 0.2, 0.4, 0.5 ]
668                                        },
669                                ]
670                        }
671         *
672         */
673        def formatData( type, groupedData, fields, groupAxisType, valueAxisType, groupAxis = "x", valueAxis = "y", errorName = "error" ) {
674                // We want to sort the data based on the group-axis, but keep the values on the value-axis in sync.
675                // The only way seems to be to combine data from both axes.
676                def combined = []
677                groupedData[ groupAxis ].eachWithIndex { group, i ->
678                        combined << [ "group": group, "value": groupedData[ valueAxis ][ i ] ]
679                }
680                combined.sort { it.group }
681               
682        // TODO: Handle name and unit of fields correctly
683        def valueAxisTypeString = (valueAxisType==CATEGORICALDATA || valueAxisType==DATE || valueAxisType==RELTIME ? "categorical" : "numerical")
684        def groupAxisTypeString = (groupAxisType==CATEGORICALDATA || groupAxisType==DATE || groupAxisType==RELTIME ? "categorical" : "numerical")
685        groupedData[groupAxis] = renderTimesAndDatesHumanReadable(combined*.group, groupAxisType)
686        groupedData[valueAxis] = renderTimesAndDatesHumanReadable(combined*.value, valueAxisType)
687
688        if(type=="table"){
689            def return_data = [:]
690            return_data[ "type" ] = type
691            return_data.put("yaxis", ["title" : parseFieldId( fields[ valueAxis ] ).name, "unit" : "", "type":valueAxisTypeString ])
692            return_data.put("xaxis", ["title" : parseFieldId( fields[ groupAxis ] ).name, "unit": "", "type":groupAxisTypeString ])
693            return_data.put("series", [[
694                    "x": groupedData[ groupAxis ].collect { it.toString() },
695                    "y": groupedData[ valueAxis ].collect { it.toString() },
696                    "data": groupedData["data"]
697            ]])
698            return return_data;
699        } else {
700            def xAxis = groupedData[ groupAxis ].collect { it.toString() };
701            def yName = parseFieldId( fields[ valueAxis ] ).name;
702
703            def return_data = [:]
704            return_data[ "type" ] = type
705            return_data.put("yaxis", ["title" : yName, "unit" : "", "type":valueAxisTypeString ])
706            return_data.put("xaxis", ["title" : parseFieldId( fields[ groupAxis ] ).name, "unit": "", "type":groupAxisTypeString  ])
707            return_data.put("series", [[
708                "name": yName,
709                "x": xAxis,
710                "y": groupedData[ valueAxis ],
711                "error": groupedData[ errorName ]
712            ]])
713
714            return return_data;
715        }
716        }
717
718    /**
719     * If the input variable 'data' contains dates or times according to input variable 'fieldInfo', these dates and times are converted to a human-readable version.
720     * @param data  The list of items that needs to be checked/converted
721     * @param axisType As determined by determineFieldType
722     * @return The input variable 'data', with it's date and time elements converted.
723     * @see determineFieldType
724     */
725    def renderTimesAndDatesHumanReadable(data, axisType){
726        if(axisType==RELTIME){
727            data = renderTimesHumanReadable(data)
728        }
729        if(axisType==DATE){
730           data = renderDatesHumanReadable(data)
731        }
732        return data
733    }
734
735    /**
736     * Takes a one-dimensional list, returns the list with the appropriate items converted to a human readable string
737     * @param data
738     * @return
739     */
740    def renderTimesHumanReadable(data){
741        def tmpTimeContainer = []
742        data. each {
743            if(it instanceof Number) {
744                try{
745                    tmpTimeContainer << new RelTime( it ).toPrettyString()
746                } catch(IllegalArgumentException e){
747                    tmpTimeContainer << it
748                }
749            } else {
750                tmpTimeContainer << it // To handle items such as 'unknown'
751            }
752        }
753        return tmpTimeContainer
754    }
755
756    /**
757     * Takes a one-dimensional list, returns the list with the appropriate items converted to a human readable string
758     * @param data
759     * @return
760     */
761    def renderDatesHumanReadable(data) {
762        def tmpDateContainer = []
763        data. each {
764            if(it instanceof Number) {
765                try{
766                    tmpDateContainer << new java.util.Date( (Long) it ).toString()
767                } catch(IllegalArgumentException e){
768                    tmpDateContainer << it
769                }
770            } else {
771                tmpDateContainer << it // To handle items such as 'unknown'
772            }
773        }
774        return tmpDateContainer
775    }
776        /**
777         * Returns a closure for the given entitytype that determines the value for a criterion
778         * on the given object. The closure receives two parameters: the sample and a field.
779         *
780         * For example:
781         *              How can one retrieve the value for subject.name, given a sample? This can be done by
782         *              returning the field values sample.parentSubject:
783         *                      { sample, field -> return getFieldValue( sample.parentSubject, field ) }
784         * @return      Closure that retrieves the value for a field and the given field
785         */
786        protected Closure valueCallback( String entity ) {
787                switch( entity ) {
788                        case "Study":
789                        case "studies":
790                                return { sample, field -> return getFieldValue( sample.parent, field ) }
791                        case "Subject":
792                        case "subjects":
793                                return { sample, field -> return getFieldValue( sample.parentSubject, field ); }
794                        case "Sample":
795                        case "samples":
796                                return { sample, field -> return getFieldValue( sample, field ) }
797                        case "Event":
798                        case "events":
799                                return { sample, field ->
800                                        if( !sample || !sample.parentEventGroup || !sample.parentEventGroup.events || sample.parentEventGroup.events.size() == 0 )
801                                                return null
802
803                                        return sample.parentEventGroup.events?.collect { getFieldValue( it, field ) };
804                                }
805                        case "SamplingEvent":
806                        case "samplingEvents":
807                                return { sample, field -> return getFieldValue( sample.parentEvent, field ); }
808                        case "Assay":
809                        case "assays":
810                                return { sample, field ->
811                                        def sampleAssays = Assay.findByParent( sample.parent ).findAll { it.samples?.contains( sample ) };
812                                        if( sampleAssays && sampleAssays.size() > 0 )
813                                                return sampleAssays.collect { getFieldValue( it, field ) }
814                                        else
815                                                return null
816                                }
817                }
818        }
819       
820        /**
821        * Returns the domain object that should be used with the given entity string
822        *
823        * For example:
824        *               What object should be consulted if the user asks for "studies"
825        *               Response: Study
826        * @return       Domain object that should be used with the given entity string
827        */
828   protected def domainObjectCallback( String entity ) {
829           switch( entity ) {
830                   case "Study":
831                   case "studies":
832                           return Study
833                   case "Subject":
834                   case "subjects":
835                           return Subject
836                   case "Sample":
837                   case "samples":
838                           return Sample
839                   case "Event":
840                   case "events":
841                        return Event
842                   case "SamplingEvent":
843                   case "samplingEvents":
844                           return SamplingEvent
845                   case "Assay":
846                   case "assays":
847                                return Assay
848           }
849   }
850
851    /**
852    * Returns the objects within the given study that should be used with the given entity string
853    *
854    * For example:
855    *           What object should be consulted if the user asks for "samples"
856    *           Response: study.samples
857    * @return   List of domain objects that should be used with the given entity string
858    */
859    protected def templateObjectCallback( String entity, Study study ) {
860      switch( entity ) {
861          case "Study":
862          case "studies":
863              return study
864          case "Subject":
865          case "subjects":
866              return study?.subjects
867          case "Sample":
868          case "samples":
869              return study?.samples
870          case "Event":
871          case "events":
872               return study?.events
873          case "SamplingEvent":
874          case "samplingEvents":
875              return study?.samplingEvents
876          case "Assay":
877          case "assays":
878                  return study?.assays
879      }
880    }
881       
882        /**
883         * Computes the mean value and Standard Error of the mean (SEM) for the given values
884         * @param values        List of values to compute the mean and SEM for. Strings and null
885         *                                      values are ignored
886         * @return                      Map with two keys: 'value' and 'error'
887         */
888        protected Map computeMeanAndError( values ) {
889                // TODO: Handle the case that one of the values is a list. In that case,
890                // all values should be taken into account.     
891                def mean = computeMean( values );
892                def error = computeSEM( values, mean );
893               
894                return [
895                        "value": mean,
896                        "error": error
897                ]
898        }
899       
900        /**
901         * Computes the mean of the given values. Values that can not be parsed to a number
902         * are ignored. If no values are given, null is returned.
903         * @param values        List of values to compute the mean for
904         * @return                      Arithmetic mean of the values
905         */
906        protected def computeMean( List values ) {
907                def sumOfValues = 0;
908                def sizeOfValues = 0;
909                values.each { value ->
910                        def num = getNumericValue( value );
911                        if( num != null ) {
912                                sumOfValues += num;
913                                sizeOfValues++
914                        }
915                }
916
917                if( sizeOfValues > 0 )
918                        return sumOfValues / sizeOfValues;
919                else
920                        return null;
921        }
922
923        /**
924        * Computes the standard error of mean of the given values.
925        * Values that can not be parsed to a number are ignored.
926        * If no values are given, null is returned.
927        * @param values         List of values to compute the standard deviation for
928        * @param mean           Mean of the list (if already computed). If not given, the mean
929        *                                       will be computed using the computeMean method
930        * @return                       Standard error of the mean of the values or 0 if no values can be used.
931        */
932    protected def computeSEM( List values, def mean = null ) {
933       if( mean == null )
934            mean = computeMean( values )
935
936       def sumOfDifferences = 0;
937       def sizeOfValues = 0;
938       values.each { value ->
939           def num = getNumericValue( value );
940           if( num != null ) {
941               sumOfDifferences += Math.pow( num - mean, 2 );
942               sizeOfValues++
943           }
944       }
945
946       if( sizeOfValues > 0 ) {
947           def std = Math.sqrt( sumOfDifferences / sizeOfValues );
948           return std / Math.sqrt( sizeOfValues );
949       } else {
950           return null;
951       }
952    }
953
954    /**
955         * Computes the median of the given values. Values that can not be parsed to a number
956         * are ignored. If no values are given, null is returned.
957         * @param values        List of values to compute the median for
958         * @return                      Median of the values
959         */
960        protected def computeMedian( List values ) {
961                def listOfValues = [];
962                values.each { value ->
963                        def num = getNumericValue( value );
964                        if( num != null ) {
965                                listOfValues << num;
966                        }
967                }
968
969        listOfValues.sort();
970
971        def listSize = listOfValues.size();
972
973                if( listSize > 0 ) {
974            def listHalf = (int) Math.abs(listSize/2);
975            if(listSize%2==0) {
976                // If the list is of an even size, take the mean of the middle two value's
977                return ["value": (listOfValues.get(listHalf)+listOfValues.get(listHalf-1))/2];
978            } else {
979                // If the list is of an odd size, take the middle value
980                return ["value": listOfValues.get(listHalf-1)];
981            }
982        } else
983                        return ["value": null];
984        }
985
986    /**
987         * Computes the count of the given values. Values that can not be parsed to a number
988         * are ignored. If no values are given, null is returned.
989         * @param values        List of values to compute the count for
990         * @return                      Count of the values
991         */
992        protected def computeCount( List values ) {
993                def sumOfValues = 0;
994                def sizeOfValues = 0;
995                values.each { value ->
996                        def num = getNumericValue( value );
997                        if( num != null ) {
998                                sumOfValues += num;
999                                sizeOfValues++
1000                        }
1001                }
1002
1003                if( sizeOfValues > 0 )
1004                        return ["value": sizeOfValues];
1005                else
1006                        return ["value": null];
1007        }
1008
1009    /**
1010         * Computes the sum of the given values. Values that can not be parsed to a number
1011         * are ignored. If no values are given, null is returned.
1012         * @param values        List of values to compute the sum for
1013         * @return                      Arithmetic sum of the values
1014         */
1015        protected def computeSum( List values ) {
1016                def sumOfValues = 0;
1017                def sizeOfValues = 0;
1018                values.each { value ->
1019                        def num = getNumericValue( value );
1020                        if( num != null ) {
1021                                sumOfValues += num;
1022                                sizeOfValues++
1023                        }
1024                }
1025
1026                if( sizeOfValues > 0 )
1027                        return ["value": sumOfValues];
1028                else
1029                        return ["value": null];
1030        }
1031   
1032        /**
1033         * Return the numeric value of the given object, or null if no numeric value could be returned
1034         * @param       value   Object to return the value for
1035         * @return                      Number that represents the given value
1036         */
1037        protected Number getNumericValue( value ) {
1038                // TODO: handle special types of values
1039                if( value instanceof Number ) {
1040                        return value;
1041                } else if( value instanceof RelTime ) {
1042                        return value.value;
1043                }
1044               
1045                return null
1046        }
1047
1048        /**
1049         * Returns a field for a given templateentity
1050         * @param object        TemplateEntity (or subclass) to retrieve data for
1051         * @param fieldName     Name of the field to return data for.
1052         * @return                      Value of the field or null if the value could not be retrieved
1053         */
1054        protected def getFieldValue( TemplateEntity object, String fieldName ) {
1055                if( !object || !fieldName )
1056                        return null;
1057               
1058                try {
1059                        return object.getFieldValue( fieldName );
1060                } catch( Exception e ) {
1061                        return null;
1062                }
1063        }
1064
1065        /**
1066         * Parses a fieldId that has been created earlier by createFieldId
1067         * @param fieldId       FieldId to parse
1068         * @return                      Map with attributes of the selected field. Keys are 'name', 'id', 'source' and 'type'
1069         * @see createFieldId
1070         */
1071        protected Map parseFieldId( String fieldId ) {
1072                def attrs = [:]
1073               
1074                def parts = fieldId.split(",")
1075               
1076                attrs = [
1077                        "id": parts[ 0 ],
1078                        "name": parts[ 1 ],
1079                        "source": parts[ 2 ],
1080                        "type": parts[ 3 ]
1081                ]
1082        }
1083       
1084        /**
1085         * Create a fieldId based on the given attributes
1086         * @param attrs         Map of attributes for this field. Keys may be 'name', 'id', 'source' and 'type'
1087         * @return                      Unique field ID for these parameters
1088         * @see parseFieldId
1089         */
1090        protected String createFieldId( Map attrs ) {
1091                // TODO: What if one of the attributes contains a comma?
1092                def name = attrs.name;
1093                def id = attrs.id ?: name;
1094                def source = attrs.source;
1095                def type = attrs.type ?: ""
1096               
1097                return id + "," + name + "," + source + "," + type;
1098        }
1099
1100    /**
1101     * Set the response code and an error message
1102     * @param code HTTP status code
1103     * @param msg Error message, string
1104     */
1105    protected void returnError(code, msg){
1106        response.sendError(code , msg)
1107    }
1108
1109    /**
1110     * Determines what type of data a field contains
1111     * @param studyId An id that can be used with Study.get/1 to retrieve a study from the database
1112     * @param fieldId The field id as returned from the client, will be used to retrieve the data required to determine the type of data a field contains
1113     * @param inputData Optional parameter that contains the data we are computing the type of. When including in the function call we do not need to request data from a module, should the data belong to a module
1114     * @return Either CATEGORICALDATA, NUMERICALDATA, DATE or RELTIME
1115     */
1116    protected int determineFieldType(studyId, fieldId, inputData = null){
1117                def parsedField = parseFieldId( fieldId );
1118        def study = Study.get(studyId)
1119                def data = []
1120
1121        try{
1122            if( parsedField.source == "GSCF" ) {
1123                if(parsedField.id.isNumber()){
1124                        return determineCategoryFromTemplateFieldId(parsedField.id)
1125                } else { // Domainfield or memberclass
1126                    def callback = domainObjectCallback( parsedField.type )
1127
1128                    // Can the field be found in the domainFields as well? If so, treat it as a template field, so that dates and times can be properly rendered in a human-readable fashion
1129                    if(callback?.giveDomainFields().name.contains(parsedField.name.toString())){
1130                        // Use the associated templateField to determine the field type
1131                        return determineCategoryFromTemplateField(
1132                                callback?.giveDomainFields()[
1133                                    callback?.giveDomainFields().name.indexOf(parsedField.name.toString())
1134                                ]
1135                        )
1136                    }
1137                    // Apparently it is not a templatefield as well as a memberclass
1138
1139                    def field = callback?.declaredFields.find { it.name == parsedField.name };
1140                    if( field ) {
1141                        return determineCategoryFromClass( field.getType() )
1142                    } else {
1143                        // TODO: how do we communicate this to the user? Do we allow the process to proceed?
1144                        log.error( "The user asked for field " + parsedField.type + " - " + parsedField.name + ", but it doesn't exist." );
1145                    }
1146                }
1147            } else {
1148                if(inputData == null){ // If we did not get data, we need to request it from the module first
1149                    data = getModuleData( study, study.getSamples(), parsedField.source, parsedField.name );
1150                    return determineCategoryFromData(data)
1151                } else {
1152                    return determineCategoryFromData(inputData)
1153                }
1154            }
1155        } catch(Exception e){
1156            log.error("VisualizationController: determineFieldType: "+e)
1157            e.printStackTrace()
1158            // If we cannot figure out what kind of a datatype a piece of data is, we treat it as categorical data
1159            return CATEGORICALDATA
1160        }
1161    }
1162
1163    /**
1164     * Determines a field category, based on the input parameter 'classObject', which is an instance of type 'class'
1165     * @param classObject
1166     * @return Either CATEGORICALDATA of NUMERICALDATA
1167     */
1168    protected int determineCategoryFromClass(classObject){
1169        log.trace "Determine category from class: "+classObject+", of class: "+classObject?.class
1170        if(classObject==java.lang.String){
1171            return CATEGORICALDATA
1172        } else {
1173            return NUMERICALDATA
1174        }
1175    }
1176
1177    /**
1178     * Determines a field category based on the actual data contained in the field. The parameter 'inputObject' can be a single item with a toString() function, or a collection of such items.
1179     * @param inputObject Either a single item, or a collection of items
1180     * @return Either CATEGORICALDATA of NUMERICALDATA
1181     */
1182    protected int determineCategoryFromData(inputObject){
1183        def results = []
1184               
1185        if(inputObject instanceof Collection){
1186            // This data is more complex than a single value, so we will call ourselves again so we c
1187            inputObject.each {
1188                                if( it != null )
1189                        results << determineCategoryFromData(it)
1190            }
1191        } else {
1192                        // Unfortunately, the JSON null object doesn't resolve to false or equals null. For that reason, we
1193                        // exclude those objects explicitly here.
1194                        if( inputObject != null && inputObject?.class != org.codehaus.groovy.grails.web.json.JSONObject$Null ) {
1195                    if(inputObject.toString().isDouble()){
1196                        results << NUMERICALDATA
1197                    } else {
1198                        results << CATEGORICALDATA
1199                    }
1200                        }
1201        }
1202
1203        results.unique()
1204
1205        if(results.size() > 1) {
1206            // If we cannot figure out what kind of a datatype a piece of data is, we treat it as categorical data
1207            results[0] = CATEGORICALDATA
1208        } else if( results.size() == 0 ) {
1209                        // If the list is empty, return the numerical type. If it is the only value, if will
1210                        // be discarded later on. If there are more entries (e.g part of a collection)
1211                        // the values will be regarded as numerical, if the other values are numerical 
1212                        results[ 0 ] = NUMERICALDATA
1213        }
1214
1215                return results[0]
1216    }
1217
1218    /**
1219     * Determines a field category, based on the TemplateFieldId of a Templatefield
1220     * @param id A database ID for a TemplateField
1221     * @return Either CATEGORICALDATA of NUMERICALDATA
1222     */
1223    protected int determineCategoryFromTemplateFieldId(id){
1224        TemplateField tf = TemplateField.get(id)
1225        return determineCategoryFromTemplateField(tf)
1226    }
1227
1228    /**
1229     * Determines a field category, based on the TemplateFieldType of a Templatefield
1230     * @param id A database ID for a TemplateField
1231     * @return Either CATEGORICALDATA of NUMERICALDATA
1232     */
1233    protected int determineCategoryFromTemplateField(tf){
1234        if(tf.type==TemplateFieldType.DOUBLE || tf.type==TemplateFieldType.LONG){
1235            log.trace "GSCF templatefield: NUMERICALDATA ("+NUMERICALDATA+") (based on "+tf.type+")"
1236            return NUMERICALDATA
1237        }
1238        if(tf.type==TemplateFieldType.DATE){
1239            log.trace "GSCF templatefield: DATE ("+DATE+") (based on "+tf.type+")"
1240            return DATE
1241        }
1242        if(tf.type==TemplateFieldType.RELTIME){
1243            log.trace "GSCF templatefield: RELTIME ("+RELTIME+") (based on "+tf.type+")"
1244            return RELTIME
1245        }
1246        log.trace "GSCF templatefield: CATEGORICALDATA ("+CATEGORICALDATA+") (based on "+tf.type+")"
1247        return CATEGORICALDATA
1248    }
1249    /**
1250     * Properly formats the object that will be returned to the client. Also adds an informational message, if that message has been set by a function. Resets the informational message to the empty String.
1251     * @param returnData The object containing the data
1252     * @return results A JSON object
1253     */
1254    protected void sendResults(returnData){
1255        def results = [:]
1256        if(infoMessage.size()!=0){
1257            results.put("infoMessage", infoMessage)
1258            infoMessage = []
1259        }
1260        results.put("returnData", returnData)
1261        render results as JSON
1262    }
1263
1264    /**
1265     * Properly formats an informational message that will be returned to the client. Resets the informational message to the empty String.
1266     * @param returnData The object containing the data
1267     * @return results A JSON object
1268     */
1269    protected void sendInfoMessage(){
1270        def results = [:]
1271        results.put("infoMessage", infoMessage)
1272        infoMessage = []
1273        render results as JSON
1274    }
1275
1276    /**
1277     * Adds a new message to the infoMessage
1278     * @param message The information that needs to be added to the infoMessage
1279     */
1280    protected void setInfoMessage(message){
1281        infoMessage.add(message)
1282        log.trace "setInfoMessage: "+infoMessage
1283    }
1284
1285    /**
1286     * Adds a message to the infoMessage that gives the client information about offline modules
1287     */
1288    protected void setInfoMessageOfflineModules(){
1289        infoMessageOfflineModules.unique()
1290        if(infoMessageOfflineModules.size()>0){
1291            String message = "Unfortunately"
1292            infoMessageOfflineModules.eachWithIndex{ it, index ->
1293                if(index==(infoMessageOfflineModules.size()-2)){
1294                    message += ', the '+it+' and '
1295                } else {
1296                    if(index==(infoMessageOfflineModules.size()-1)){
1297                        message += ' the '+it
1298                    } else {
1299                        message += ', the '+it
1300                    }
1301                }
1302            }
1303            message += " could not be reached. As a result, we cannot at this time visualize data contained in "
1304            if(infoMessageOfflineModules.size()>1){
1305                message += "these modules."
1306            } else {
1307                message += "this module."
1308            }
1309            setInfoMessage(message)
1310        }
1311        infoMessageOfflineModules = []
1312    }
1313
1314    /**
1315     * Combine several blocks of formatted data into one. These blocks have been formatted by the formatData function.
1316     * @param inputData Contains a list of maps, of the following format
1317     *          - a key 'series' containing a list, that contains one or more maps, which contain the following:
1318     *            - a key 'name', containing, for example, a feature name or field name
1319     *            - a key 'y', containing a list of y-values
1320     *            - a key 'error', containing a list of, for example, standard deviation or standard error of the mean values,
1321     */
1322    protected def formatCategoryData(inputData){
1323        // NOTE: This function is no longer up to date with the current inputData layout.
1324        def series = []
1325        inputData.eachWithIndex { it, i ->
1326            series << ['name': it['yaxis']['title'], 'y': it['series']['y'][0], 'error': it['series']['error'][0]]
1327        }
1328        def ret = [:]
1329        ret.put('type', inputData[0]['type'])
1330        ret.put('x', inputData[0]['x'])
1331        ret.put('yaxis',['title': 'title', 'unit': ''])
1332        ret.put('xaxis', inputData[0]['xaxis'])
1333        ret.put('series', series)
1334        return ret
1335    }
1336
1337    /**
1338     * Given two objects of either CATEGORICALDATA or NUMERICALDATA
1339     * @param rowType The type of the data that has been selected for the row, either CATEGORICALDATA or NUMERICALDATA
1340     * @param columnType The type of the data that has been selected for the column, either CATEGORICALDATA or NUMERICALDATA
1341     * @return
1342     */
1343    protected def determineVisualizationTypes(rowType, columnType){
1344         def types = []
1345        if(rowType==CATEGORICALDATA || DATE || RELTIME){
1346            if(columnType==CATEGORICALDATA || DATE || RELTIME){
1347                types = [ [ "id": "table", "name": "Table"] ];
1348            }
1349            if(columnType==NUMERICALDATA){
1350                types = [ [ "id": "horizontal_barchart", "name": "Horizontal barchart"] ];
1351            }
1352        }
1353        if(rowType==NUMERICALDATA){
1354            if(columnType==CATEGORICALDATA || DATE || RELTIME){
1355                types = [ [ "id": "barchart", "name": "Barchart"], [ "id": "linechart", "name": "Linechart"] ];
1356            }
1357            if(columnType==NUMERICALDATA){
1358                types = [ [ "id": "scatterplot", "name": "Scatterplot"], [ "id": "linechart", "name": "Linechart"] ];
1359            }
1360        }
1361        return types
1362    }
1363}
Note: See TracBrowser for help on using the browser.