source: trunk/grails-app/controllers/dbnp/visualization/VisualizeController.groovy @ 2114

Last change on this file since 2114 was 2114, checked in by tjeerd@…, 11 years ago

New version of JQplot, a new interface and a fix for VIS-56

File size: 62.5 KB
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                        // Also make sure the user can select eventGroup to visualize
95                        fields += formatGSCFFields( "domainfields", [ name: "name" ], "GSCF", "eventGroups" );
96                       
97            /*
98            Gather fields related to this study from modules.
99            This will use the getMeasurements RESTful service. That service returns measurement types, AKA features.
100            It does not actually return measurements (the getMeasurementData call does).
101            The getFields method (or rather, the getMeasurements service) requires one or more assays and will return all measurement
102            types related to these assays.
103            So, the required variables for such a call are:
104              - a source variable, which can be obtained from AssayModule.list() (use the 'name' field)
105              - an assay, which can be obtained with study.getAssays()
106             */
107            study.getAssays().each { assay ->
108                def list = []
109                if(!offlineModules.contains(assay.module.id)){
110                    list = getFields(assay.module.toString(), assay)
111                    if(list!=null){
112                        if(list.size()!=0){
113                            fields += list
114                        }
115                    }
116                }
117            }
118            offlineModules = []
119
120            // Make sure any informational messages regarding offline modules are submitted to the client
121            setInfoMessageOfflineModules()
122
123
124            // TODO: Maybe we should add study's own fields
125        } else {
126            log.error("VisualizationController: getFields: The requested study could not be found. Id: "+studies)
127            return returnError(404, "The requested study could not be found.")
128        }
129
130        fields.unique() // Todo: find out root cause of why some fields occur more than once
131        fields.sort { a, b ->
132            def sourceEquality = a.source.toString().toLowerCase().compareTo(b.source.toString().toLowerCase())
133            if( sourceEquality == 0 ) {
134                def categoryEquality = a.category.toString().toLowerCase().compareTo(b.category.toString().toLowerCase())
135                if( categoryEquality == 0 ){
136                    a.name.toString().toLowerCase().compareTo(b.name.toString().toLowerCase())
137                } else return categoryEquality
138            } else return sourceEquality
139        }
140                return sendResults(['studyIds': studies, 'fields': fields])
141        }
142
143        /**
144         * 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.
145         * @return List containing the possible visualization types, with each element containing
146     *          - a unique id
147     *          - a unique name
148     *         For example: ["id": "barchart", "name": "Barchart"]
149     * @see parseGetDataParams
150         * @see determineFieldType
151     * @see determineVisualizationTypes
152         */
153        def getVisualizationTypes = {
154        def inputData = parseGetDataParams();
155
156        if(inputData.columnIds == null || inputData.columnIds == [] || inputData.columnIds[0] == null || inputData.columnIds[0] == ""){
157            setInfoMessage("Please select a data source for the x-axis.")
158            return sendInfoMessage()
159        }
160        if(inputData.rowIds == null || inputData.rowIds == [] ||  inputData.rowIds[0] == null ||   inputData.rowIds[0] == ""){
161            setInfoMessage("Please select a data source for the y-axis.")
162            return sendInfoMessage()
163        }
164
165        // TODO: handle the case of multiple fields on an axis
166        // Determine data types
167        log.trace "Determining rowType: "+inputData.rowIds[0]
168        def rowType = determineFieldType(inputData.studyIds[0], inputData.rowIds[0])
169       
170                log.trace "Determining columnType: "+inputData.columnIds[0]
171        def columnType = determineFieldType(inputData.studyIds[0], inputData.columnIds[0])
172
173                log.trace "Determining groupType: "+inputData.groupIds[0]
174                def groupType = determineFieldType(inputData.studyIds[0], inputData.groupIds[0])
175               
176                        // Determine possible visualization- and aggregationtypes
177        def visualizationTypes = determineVisualizationTypes(rowType, columnType)
178                def aggregationTypes = determineAggregationTypes(rowType, columnType, groupType)
179               
180        log.trace  "visualization types: " + visualizationTypes + ", determined this based on "+rowType+" and "+columnType
181                log.trace  "aggregation   types: " + aggregationTypes + ", determined this based on "+rowType+" and "+columnType + " and " + groupType
182               
183                def fieldData = [ 'x': parseFieldId( inputData.columnIds[ 0 ] ), 'y': parseFieldId( inputData.rowIds[ 0 ] ) ];
184               
185        return sendResults([
186                        'types': visualizationTypes,
187                        'aggregations': aggregationTypes,
188                       
189                        // TODO: Remove these ids when the view has been updated. Use xaxis.id and yaxis.id instead
190                        'rowIds':inputData.rowIds[0],
191                        'columnIds':inputData.columnIds[0],
192                       
193                        'xaxis': [ 
194                                'id': fieldData.x.fieldId,
195                                'name': fieldData.x.name,
196                                'unit': fieldData.x.unit,
197                                'type': dataTypeString( columnType )
198                        ],
199                        'yaxis': [
200                                'id': fieldData.y.fieldId,
201                                'name': fieldData.y.name,
202                                'unit': fieldData.y.unit,
203                                'type': dataTypeString( rowType )
204                        ],
205
206                ])
207        }
208
209    /**
210     * Gather fields related to this study from modules.
211        This will use the getMeasurements RESTful service. That service returns measurement types, AKA features.
212        getMeasurements does not actually return measurements (the getMeasurementData call does).
213     * @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)
214     * @param assay     The assay that the source module and the requested fields belong to
215     * @return  A list of map objects, containing the following:
216     *           - a key 'id' with a value formatted by the createFieldId function
217     *           - a key 'source' with a value equal to the input parameter 'source'
218     *           - a key 'category' with a value equal to the 'name' field of the input paramater 'assay'
219     *           - a key 'name' with a value equal to the name of the field in question, as determined by the source value
220     */
221    def getFields(source, assay) {
222        def fields = []
223        def callUrl = ""
224
225        // Making a different call for each assay
226        def urlVars = "assayToken="+assay.assayUUID
227        try {
228            callUrl = ""+assay.module.url + "/rest/getMeasurementMetaData/query?"+urlVars
229            def json = moduleCommunicationService.callModuleRestMethodJSON( assay.module.url /* consumer */, callUrl );
230
231            def collection = []
232            json.each{ jason ->
233                collection.add(jason)
234            }
235            // Formatting the data
236            collection.each { field ->
237                // For getting this field from this assay
238                fields << [ "id": createFieldId( id: field.name, name: field.name, source: ""+assay.id, type: ""+assay.name, unit: (field.unit?:"")), "source": source, "category": ""+assay.name, "name": field.name + (field.unit?" ("+field.unit+")":"")  ]
239            }
240        } catch(Exception e){
241            //returnError(404, "An error occured while trying to collect field data from a module. Most likely, this module is offline.")
242            offlineModules.add(assay.module.id)
243            infoMessageOfflineModules.add(assay.module.name)
244            log.error("VisualizationController: getFields: "+e)
245        }
246
247        return fields
248    }
249
250    /**
251     * Gather fields related to this study from GSCF.
252     * @param study The study that is the source of the requested fields
253     * @param category  The domain that a field (a property in this case) belongs to, e.g. "subjects", "samplingEvents"
254     * @param type A string that indicates the type of field, either "domainfields" or "templatefields".
255     * @return A list of map objects, formatted by the formatGSCFFields function
256     */
257    def getFields(study, category, type){
258        // Collecting the data from it's source
259        def collection = []
260        def fields = []
261        def source = "GSCF"
262               
263                if( type == "domainfields" ) 
264                        collection = domainObjectCallback( category )?.giveDomainFields();
265                else
266                        collection = templateObjectCallback( category, study )?.template?.fields
267
268        collection?.unique()
269
270        // Formatting the data
271        fields += formatGSCFFields(type, collection, source, category)
272
273        // Here we will remove those fields, whose set of datapoints only contain null
274        def fieldsToBeRemoved = []
275        fields.each{ field ->
276            def fieldData = getFieldData( study, study.samples, field.id )
277            fieldData.removeAll([null])
278            if(fieldData==[]){
279                // Field only contained nulls, so don't show it as a visualization option
280                fieldsToBeRemoved << field
281            }
282        }
283        fields.removeAll(fieldsToBeRemoved)
284
285        return fields
286    }
287
288    /**
289     * 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
290     * @param type A string that indicates the type of field, either "domainfields" or "templatefields".
291     * @param collectionOfFields A collection of fields, which could also contain only one item
292     * @param source Likely to be "GSCF"
293     * @param category The domain that a field (a property in this case) belongs to, e.g. "subjects", "samplingEvents"
294     * @return A list containing list objects, containing the following:
295     *           - a key 'id' with a value formatted by the createFieldId function
296     *           - a key 'source' with a value equal to the input parameter 'source'
297     *           - a key 'category' with a value equal to the input parameter 'category'
298     *           - a key 'name' with a value equal to the name of the field in question, as determined by the source value
299     */
300    def formatGSCFFields(type, collectionOfFields, source, category){
301
302        if(collectionOfFields==null || collectionOfFields == []){
303            return []
304        }
305        def fields = []
306        if(collectionOfFields instanceof Collection){
307            // Apparently this field is actually a list of fields.
308            // We will call ourselves again with the list's elements as input.
309            // These list elements will themselves go through this check again, effectively flattening the original input
310            for(int i = 0; i < collectionOfFields.size(); i++){
311                fields += formatGSCFFields(type, collectionOfFields[i], source, category)
312            }
313            return fields
314        } else {
315            // This is a single field. Format it and return the result.
316            if(type=="domainfields"){
317                fields << [ "id": createFieldId( id: collectionOfFields.name, name: collectionOfFields.name, source: source, type: category, unit: (collectionOfFields.unit?:"") ), "source": source, "category": category, "name": collectionOfFields.name + (collectionOfFields.unit?" ("+collectionOfFields.unit+")":"") ]
318            }
319            if(type=="templatefields"){
320                fields << [ "id": createFieldId( id: collectionOfFields.id.toString(), name: collectionOfFields.name, source: source, type: category, unit: (collectionOfFields.unit?:"") ), "source": source, "category": category, "name": collectionOfFields.name + (collectionOfFields.unit?" ("+collectionOfFields.unit+")":"")]
321            }
322            return fields
323        }
324    }
325
326        /**
327         * Retrieves data for the visualization itself.
328     * 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.
329     * @return A map containing containing (at least, in the case of a barchart) the following:
330     *           - a key 'type' containing the type of chart that will be visualized
331     *           - a key 'xaxis' containing the title and unit that should be displayed for the x-axis
332     *           - a key 'yaxis' containing the title and unit that should be displayed for the y-axis*
333     *           - a key 'series' containing a list, that contains one or more maps, which contain the following:
334     *                - a key 'name', containing, for example, a feature name or field name
335     *                - a key 'y', containing a list of y-values
336     *                - 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
337         */
338        def getData = {
339                // Extract parameters
340                // TODO: handle erroneous input data
341                def inputData = parseGetDataParams();
342
343        if(inputData.columnIds == null || inputData.rowIds == null){
344            infoMessage = "Please select data sources for the y- and x-axes."
345            return sendInfoMessage()
346        }
347
348                // TODO: handle the case that we have multiple studies
349                def studyId = inputData.studyIds[ 0 ];
350                def study = Study.get( studyId as Integer );
351
352                // Find out what samples are involved
353                def samples = study.samples
354
355        // If the user is requesting data that concerns only subjects, then make sure those subjects appear only once
356        if(parseFieldId( inputData.columnIds[ 0 ] ).type=='subjects' && parseFieldId( inputData.rowIds[ 0 ] ).type=='subjects'){
357            samples.unique { it.parentSubject }
358        }
359       
360                // Retrieve the data for both axes for all samples
361                // TODO: handle the case of multiple fields on an axis
362                def fields = [ "x": inputData.columnIds[ 0 ], "y": inputData.rowIds[ 0 ], "group": inputData.groupIds[ 0 ] ];
363                def fieldInfo = [:]
364                fields.each { 
365                        fieldInfo[ it.key ] = parseFieldId( it.value ) 
366                       
367                        if( fieldInfo[ it.key ] )
368                                fieldInfo[ it.key ].fieldType = determineFieldType( study.id, it.value );
369                }
370               
371                // If the groupAxis is numerical, we should ignore it, unless a table is asked for
372                if( fieldInfo.group && fieldInfo.group.fieldType == NUMERICALDATA && inputData.visualizationType != "table" ) {
373                        fields.group = null;
374                        fieldInfo.group = null;
375                }
376               
377                // Fetch all data from the system. data will be in the format:
378                //              [ "x": [ 3, 6, null, 10 ], "y": [ "male", "male", "female", "female" ], "group": [ "US", "NL", "NL", "NL" ]
379                //      If a field is not given, the data will be NULL
380                def data = getAllFieldData( study, samples, fields );
381
382                // Aggregate the data based on the requested aggregation 
383                def aggregatedData = aggregateData( data, fieldInfo, inputData.aggregation );
384               
385                println "Aggregated Data: "
386                aggregatedData.each { println it }
387               
388                // No convert the aggregated data into a format we can use
389                def returnData = formatData( inputData.visualizationType, aggregatedData, fieldInfo );
390
391                println "Returndata: " 
392                returnData.each { println it }
393               
394        // Make sure no changes are written to the database
395        study.discard()
396        samples*.discard()
397       
398        return sendResults(returnData)
399        }
400
401        /**
402         * Parses the parameters given by the user into a proper list
403         * @return Map with 4 keys:
404         *              studyIds:       list with studyIds selected
405         *              rowIds:         list with fieldIds selected for the rows
406         *              columnIds:      list with fieldIds selected for the columns
407         *              visualizationType:      String with the type of visualization required
408         * @see getFields
409         * @see getVisualizationTypes
410         */
411        def parseGetDataParams() {
412                def studyIds = params.list( 'study' );
413                def rowIds = params.list( 'rows' );
414                def columnIds = params.list( 'columns' );
415                def groupIds = params.list( 'groups' ); 
416                def visualizationType = params.get( 'types');
417                def aggregation = params.get( 'aggregation' );
418
419                return [ "studyIds" : studyIds, "rowIds": rowIds, "columnIds": columnIds, "groupIds": groupIds, "visualizationType": visualizationType, "aggregation": aggregation ];
420        }
421
422        /**
423         * Retrieve the field data for the selected fields
424         * @param study         Study for which the data should be retrieved
425         * @param samples       Samples for which the data should be retrieved
426         * @param fields        Map with key-value pairs determining the name and fieldId to retrieve data for. Example:
427         *                                              [ "x": "field-id-1", "y": "field-id-3", "group": "field-id-6" ]
428         * @return                      A map with the same keys as the input fields. The values in the map are lists of values of the
429         *                                      selected field for all samples. If a value could not be retrieved for a sample, null is returned. Example:
430         *                                              [ "numValues": 4, "x": [ 3, 6, null, 10 ], "y": [ "male", "male", "female", "female" ], "group": [ "US", "NL", "NL", "NL" ] ]
431         */
432        def getAllFieldData( study, samples, fields ) {
433                def fieldData = [:]
434                def numValues = 0;
435                fields.each{ field ->
436                        def fieldId = field.value ?: null;
437                        fieldData[ field.key ] = getFieldData( study, samples, fieldId );
438                       
439                        if( fieldData[ field.key ] )
440                                numValues = Math.max( numValues, fieldData[ field.key ].size() );
441                }
442               
443                fieldData.numValues = numValues;
444               
445                return fieldData;
446        }
447       
448        /**
449        * Retrieve the field data for the selected field
450        * @param study          Study for which the data should be retrieved
451        * @param samples        Samples for which the data should be retrieved
452        * @param fieldId        ID of the field to return data for
453        * @return                       A list of values of the selected field for all samples. If a value
454        *                                       could not be retrieved for a sample, null is returned. Examples:
455        *                                               [ 3, 6, null, 10 ] or [ "male", "male", "female", "female" ]
456        */
457        def getFieldData( study, samples, fieldId ) {
458                if( !fieldId )
459                        return null
460                       
461                // Parse the fieldId as given by the user
462                def parsedField = parseFieldId( fieldId );
463               
464                def data = []
465               
466                if( parsedField.source == "GSCF" ) {
467                        // Retrieve data from GSCF itself
468                        def closure = valueCallback( parsedField.type )
469
470                        if( closure ) {
471                                samples.each { sample ->
472                                        // Retrieve the value for the selected field for this sample
473                                        def value = closure( sample, parsedField.name );
474
475                    data << value;
476                                }
477                        } else {
478                                // TODO: Handle error properly
479                                // Closure could not be retrieved, probably because the type is incorrect
480                                data = samples.collect { return null }
481                log.error("VisualizationController: getFieldData: Requested wrong field type: "+parsedField.type+". Parsed field: "+parsedField)
482                        }
483                } else {
484                        // Data must be retrieved from a module
485                        data = getModuleData( study, samples, parsedField.source, parsedField.name );
486                }
487               
488                return data
489        }
490       
491        /**
492         * Retrieve data for a given field from a data module
493         * @param study                 Study to retrieve data for
494         * @param samples               Samples to retrieve data for
495         * @param source_module Name of the module to retrieve data from
496         * @param fieldName             Name of the measurement type to retrieve (i.e. measurementToken)
497         * @return                              A list of values of the selected field for all samples. If a value
498         *                                              could not be retrieved for a sample, null is returned. Examples:
499         *                                                      [ 3, 6, null, 10 ] or [ "male", "male", "female", "female" ]
500         */
501        def getModuleData( study, samples, assay_id, fieldName ) {
502                def data = []
503               
504                // TODO: Handle values that should be retrieved from multiple assays
505        def assay = Assay.get(assay_id);
506
507        if( assay ) {
508            // Request for a particular assay and a particular feature
509            def urlVars = "assayToken=" + assay.assayUUID + "&measurementToken="+fieldName.encodeAsURL()
510            urlVars += "&" + samples.collect { "sampleToken=" + it.sampleUUID }.join( "&" );
511
512            def callUrl
513            try {
514                callUrl = assay.module.url + "/rest/getMeasurementData"
515                def json = moduleCommunicationService.callModuleMethod( assay.module.url, callUrl, urlVars, "POST" );
516
517                if( json ) {
518                    // First element contains sampletokens
519                    // Second element contains the featurename
520                    // Third element contains the measurement value
521                    def sampleTokens = json[ 0 ]
522                    def measurements = json[ 2 ]
523
524                    // Loop through the samples
525                    samples.each { sample ->
526                        // Search for this sampletoken
527                        def sampleToken = sample.sampleUUID;
528                        def index = sampleTokens.findIndexOf { it == sampleToken }
529
530                        if( index > -1 ) {
531                            data << measurements[ index ];
532                        } else {
533                            data << null
534                        }
535                    }
536                } else {
537                    // TODO: handle error
538                    // Returns an empty list with as many elements as there are samples
539                    data = samples.collect { return null }
540                }
541
542            } catch(Exception e){
543                log.error("VisualizationController: getFields: "+e)
544                //return returnError(404, "An error occured while trying to collect data from a module. Most likely, this module is offline.")
545                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.")
546            }
547        } else {
548            // TODO: Handle error correctly
549            // Returns an empty list with as many elements as there are samples
550            data = samples.collect { return null }
551        }
552
553        //println "\t data request: "+data
554                return data
555        }
556       
557        /**
558         * Aggregates the data based on the requested aggregation on the categorical fields
559         * @param data                  Map with data for each dimension as retrieved using getAllFieldData. For example:
560         *                                                      [ "x": [ 3, 6, 8, 10 ], "y": [ "male", "male", "female", "female" ], "group": [ "US", "NL", "NL", "NL" ] ]
561         * @param fieldInfo             Map with field information for each dimension. For example:
562         *                                                      [ "x": [ id: "abc", "type": NUMERICALDATA ], "y": [ "id": "def", "type": CATEGORICALDATA ] ]
563         * @param aggregation   Kind of aggregation requested
564         * @return                              Data that is aggregated on the categorical fields
565         *                                                      [ "x": [ 3, 6, null, 9 ], "y": [ "male", "male", "female", "female" ], "group": [ "US", "NL", "US", "NL" ] ]
566         *
567         */
568        def aggregateData( data, fieldInfo, aggregation ) {
569                // If no aggregation is requested, we just return the original object
570                if( aggregation == "none" )
571                        return data
572               
573                // Determine the categorical fields
574                def dimensions = [ "categorical": [], "numerical": [] ];
575                fieldInfo.each { 
576                        // If fieldInfo value is NULL, the field is not requested
577                        if( it && it.value ) {
578                                if( [ CATEGORICALDATA, RELTIME, DATE ].contains( it.value.fieldType ) ) {
579                                        dimensions.categorical << it.key
580                                } else {
581                                        dimensions.numerical << it.key
582                                }
583                        }
584                }
585               
586                // Compose a map with aggregated data
587                def aggregatedData = [:];
588                fieldInfo.each { aggregatedData[ it.key ] = [] }
589               
590                // Loop through all categorical fields and aggregate the values for each combination
591                if( dimensions.categorical.size() > 0 ) {
592                        return aggregate( data, dimensions.categorical, dimensions.numerical, aggregation, fieldInfo );
593                } else {
594                        // No categorical dimensions. Just compute the aggregation for all values together
595                        def returnData = [ "count": [ data.numValues ] ];
596                 
597                        // Now compute the correct aggregation for each numerical dimension.
598                        dimensions.numerical.each { numericalDimension ->
599                                def currentData = data[ numericalDimension ];
600                                returnData[ numericalDimension ] = [ computeAggregation( aggregation, currentData ).value ];
601                        }
602                       
603                        return returnData;
604                }
605        }
606       
607        /**
608         * Aggregates the given data on the categorical dimensions.
609         * @param data                                  Initial data
610         * @param categoricalDimensions List of categorical dimensions to group  by
611         * @param numericalDimensions   List of all numerical dimensions to compute the aggregation for
612         * @param aggregation                   Type of aggregation requested
613         * @param fieldInfo                             Information about the fields requested by the user      (e.g. [ "x": [ "id": 1, "fieldType": CATEGORICALDATA ] ] )
614         * @param criteria                              The criteria the current aggregation must keep (e.g. "x": "male")
615         * @param returnData                    Initial return object with the same keys as the data object, plus 'count'
616         * @return
617         */
618        protected def aggregate( Map data, Collection categoricalDimensions, Collection numericalDimensions, String aggregation, fieldInfo, criteria = [:], returnData = null ) {
619                if( !categoricalDimensions )
620                        return data;
621                       
622                // If no returndata is given, initialize the map
623                if( returnData == null ) {
624                        returnData = [ "count": [] ]
625                        data.each { returnData[ it.key ] = [] }
626                }
627               
628                def dimension = categoricalDimensions.head();
629               
630                // Determine the unique values on the categorical axis and sort by toString method
631                def unique = data[ dimension ].flatten()
632                                        .unique { it == null ? "null" : it.class.name + it.toString() }
633                                        .sort {
634                                                // Sort categoricaldata on its string value, but others (numerical, reltime, date)
635                                                // on its real value
636                                                switch( fieldInfo[ dimension ].fieldType ) {
637                                                        case CATEGORICALDATA:
638                                                                return it.toString()
639                                                        default:
640                                                                return it
641                                                } 
642                                        };
643                                       
644                // Make sure the null category is last
645                unique = unique.findAll { it != null } + unique.findAll { it == null }
646               
647                unique.each { el ->
648                        // Use this element to search on
649                        criteria[ dimension ] = el;
650                       
651                        // If the list of categoricalDimensions is empty after this dimension, do the real work
652                        if( categoricalDimensions.size() == 1 ) {
653                                // Search for all elements in the numericaldimensions that belong to the current group
654                                // The current group is defined by the criteria object
655                               
656                                // We start with all indices belonging to this group
657                                def indices = 0..data.numValues;
658                                criteria.each { criterion ->
659                                        // Find the indices of the samples that belong to this group. if a sample belongs to multiple groups (i.e. if
660                                        // the samples groupAxis contains multiple values, is a collection), the value should be used in all groups.
661                                        def currentIndices = data[ criterion.key ].findIndexValues { it instanceof Collection ? it.contains( criterion.value ) : it == criterion.value };
662                                        indices = indices.intersect( currentIndices );
663                                       
664                                        // Store the value for the criterion in the returnData object
665                                        returnData[ criterion.key ] << criterion.value;
666                                }
667                               
668                                // If no numericalDimension is asked for, no aggregation is possible. For that reason, we
669                                // also return counts
670                                returnData[ "count" ] << indices.size();
671                                 
672                                // Now compute the correct aggregation for each numerical dimension.
673                                numericalDimensions.each { numericalDimension ->
674                                        def currentData = data[ numericalDimension ][ indices ]; 
675                                        returnData[ numericalDimension ] << computeAggregation( aggregation, currentData ).value;
676                                }
677                               
678                        } else {
679                                returnData = aggregate( data, categoricalDimensions.tail(), numericalDimensions, aggregation, fieldInfo, criteria, returnData );
680                        }
681                }
682               
683                return returnData;
684        }
685       
686        /**
687         * Compute the aggregation for a list of values
688         * @param aggregation
689         * @param currentData
690         * @return
691         */
692        def computeAggregation( String aggregation, List currentData ) {
693                switch( aggregation ) {
694                        case "count":
695                                return computeCount( currentData );
696                                break;
697                        case "median":
698                                return computeMedian( currentData );
699                                break;
700                        case "sum":
701                                return computeSum( currentData );
702                                break;
703                        case "average":
704                        default:
705                                // Default is "average"
706                                return computeMeanAndError( currentData );
707                                break;
708                }
709        }
710
711        /**
712         * Formats the grouped data in such a way that the clientside visualization method
713         * can handle the data correctly.
714         * @param groupedData   Data that has been grouped using the groupFields method
715         * @param fieldData             Map with key-value pairs determining the name and fieldId to retrieve data for. Example:
716         *                                                      [ "x": { "id": ... }, "y": { "id": "field-id-3" }, "group": { "id": "field-id-6" } ]
717         * @param errorName             Key in the output map where 'error' values (SEM) are stored. Defaults to "error"         *
718         * @return                              A map like the following:
719         *
720                        {
721                                "type": "barchart",
722                                "xaxis": { "title": "quarter 2011", "unit": "" },
723                                "yaxis": { "title": "temperature", "unit": "degrees C" },
724                                "series": [
725                                        {
726                                                "name": "series name",
727                                                "y": [ 5.1, 3.1, 20.6, 15.4 ],
728                        "x": [ "Q1", "Q2", "Q3", "Q4" ],
729                                                "error": [ 0.5, 0.2, 0.4, 0.5 ]
730                                        },
731                                ]
732                        }
733         *
734         */
735        def formatData( type, groupedData, fieldInfo, xAxis = "x", yAxis = "y", serieAxis = "group", errorName = "error" ) {
736                // Format categorical axes by setting the names correct
737                fieldInfo.each { field, info ->
738                        if( field && info ) {
739                                groupedData[ field ] = renderFieldsHumanReadable( groupedData[ field ], info.fieldType)
740                        }
741                }
742               
743                // TODO: Handle name and unit of fields correctly
744                def xAxisTypeString = dataTypeString( fieldInfo[ xAxis ]?.fieldType )
745                def yAxisTypeString = dataTypeString( fieldInfo[ yAxis ]?.fieldType )
746                def serieAxisTypeString = dataTypeString( fieldInfo[ serieAxis ]?.fieldType )
747               
748                // Create a return object
749                def return_data = [:]
750                return_data[ "type" ] = type
751                return_data.put("xaxis", ["title" : fieldInfo[ xAxis ]?.name, "unit": fieldInfo[ xAxis ]?.unit, "type": xAxisTypeString ])
752                return_data.put("yaxis", ["title" : fieldInfo[ yAxis ]?.name, "unit" : fieldInfo[ yAxis ]?.unit, "type": yAxisTypeString ])
753                return_data.put("groupaxis", ["title" : fieldInfo[ serieAxis ]?.name, "unit" : fieldInfo[ serieAxis ]?.unit, "type": serieAxisTypeString ])
754               
755                if(type=="table"){
756                        // Determine the lists on both axes. The strange addition is done because the unique() method
757                        // alters the object itself, instead of only returning a unique list
758                        def xAxisData = ([] + groupedData[ xAxis ]).unique()
759                        def yAxisData = ([] + groupedData[ yAxis ]).unique()
760
761                        if( !fieldInfo[ serieAxis ] ) {
762                                // If no value has been chosen on the serieAxis, we should show the counts for only one serie
763                                def tableData = formatTableData( groupedData, xAxisData, yAxisData, xAxis, yAxis, "count" );
764                               
765                                return_data.put("series", [[
766                                        "name": "count",
767                                        "x": xAxisData,
768                                        "y": yAxisData,
769                                        "data": tableData
770                                ]])
771                        } else if( fieldInfo[ serieAxis ].fieldType == NUMERICALDATA ) {
772                                // If no value has been chosen on the serieAxis, we should show the counts for only one serie
773                                def tableData = formatTableData( groupedData, xAxisData, yAxisData, xAxis, yAxis, serieAxis );
774
775                                // If a numerical field has been chosen on the serieAxis, we should show the requested aggregation
776                                // for only one serie
777                                return_data.put("series", [[
778                                        "name": fieldInfo[ xAxis ].name,
779                                        "x": xAxisData,
780                                        "y": yAxisData,
781                                        "data": tableData
782                                ]])
783                        } else {
784                                // If a categorical field has been chosen on the serieAxis, we should create a table for each serie
785                                // with counts as data. That table should include all data for that serie
786                                return_data[ "series" ] = [];
787                               
788                                // The strange addition is done because the unique() method
789                                // alters the object itself, instead of only returning a unique list
790                                def uniqueSeries = ([] + groupedData[ serieAxis ]).unique();
791                               
792                                uniqueSeries.each { serie -> 
793                                        def indices = groupedData[ serieAxis ].findIndexValues { it == serie }
794                                       
795                                        // If no value has been chosen on the serieAxis, we should show the counts for only one serie
796                                        def tableData = formatTableData( groupedData, xAxisData, yAxisData, xAxis, yAxis, "count", indices );
797       
798                                        return_data[ "series" ] << [
799                                                "name": serie,
800                                                "x": xAxisData,
801                                                "y": yAxisData,
802                                                "data": tableData,
803                                        ]
804                                }
805                        }
806                       
807                } else {
808                        // For a horizontal barchart, the two axes should be swapped
809                        if( type == "horizontal_barchart" ) {
810                                def tmp = xAxis
811                                xAxis = yAxis
812                                yAxis = tmp
813                        }
814               
815                        if( !fieldInfo[ serieAxis ] ) {
816                                // If no series field has defined, we return all data in one serie
817                                return_data.put("series", [[
818                                        "name": "count",
819                                        "x": groupedData[ xAxis ],
820                                        "y": groupedData[ yAxis ],
821                                ]])
822                        } else if( fieldInfo[ serieAxis ].fieldType == NUMERICALDATA ) {
823                                // No numerical series field is allowed in a chart.
824                                throw new Exception( "No numerical series field is allowed here." );
825                        } else {
826                                // If a categorical field has been chosen on the serieAxis, we should create a group for each serie
827                                // with the correct values, belonging to that serie.
828                                return_data[ "series" ] = [];
829                               
830                                // The unique method alters the original object, so we
831                                // create a new object
832                                def uniqueSeries = ([] + groupedData[ serieAxis ]).unique();
833                               
834                                uniqueSeries.each { serie ->
835                                        def indices = groupedData[ serieAxis ].findIndexValues { it == serie }
836                                        return_data[ "series" ] << [
837                                                "name": serie,
838                                                "x": groupedData[ xAxis ][ indices ],
839                                                "y": groupedData[ yAxis ][ indices ]
840                                        ]
841                                }
842                        }
843                }
844               
845                return return_data;
846        }
847
848        /**
849         * Formats the requested data for a table       
850         * @param groupedData
851         * @param xAxisData
852         * @param yAxisData
853         * @param xAxis
854         * @param yAxis
855         * @param dataAxis
856         * @return
857         */
858        def formatTableData( groupedData, xAxisData, yAxisData, xAxis, yAxis, dataAxis, serieIndices = null ) {
859                def tableData = []
860               
861                xAxisData.each { x ->
862                        def colData = []
863                       
864                        def indices = groupedData[ xAxis ].findIndexValues { it == x }
865                       
866                        // If serieIndices are given, intersect the indices
867                        if( serieIndices != null )
868                                indices = indices.intersect( serieIndices );
869                       
870                        yAxisData.each { y ->
871                                def index = indices.intersect( groupedData[ yAxis ].findIndexValues { it == y } );
872                               
873                                if( index.size() ) {
874                                        colData << groupedData[ dataAxis ][ (int) index[ 0 ] ]
875                                }
876                        }
877                        tableData << colData;
878                }
879               
880                return tableData;
881        }
882
883    /**
884     * 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.
885     * @param data  The list of items that needs to be checked/converted
886     * @param axisType As determined by determineFieldType
887     * @return The input variable 'data', with it's date and time elements converted.
888     * @see determineFieldType
889     */
890    def renderFieldsHumanReadable(data, axisType){
891        switch( axisType ) {
892                        case RELTIME:
893                                return renderTimesHumanReadable(data)
894                        case DATE:
895                                return renderDatesHumanReadable(data)
896                        case CATEGORICALDATA:
897                                return data.collect { it.toString() }
898                        case NUMERICALDATA:
899                        default:
900                                return data;
901                }
902    }
903
904    /**
905     * Takes a one-dimensional list, returns the list with the appropriate items converted to a human readable string
906     * @param data
907     * @return
908     */
909    def renderTimesHumanReadable(data){
910        def tmpTimeContainer = []
911        data. each {
912            if(it instanceof Number) {
913                try{
914                    tmpTimeContainer << new RelTime( it ).toPrettyString()
915                } catch(IllegalArgumentException e){
916                    tmpTimeContainer << it
917                }
918            } else {
919                tmpTimeContainer << it // To handle items such as 'unknown'
920            }
921        }
922        return tmpTimeContainer
923    }
924
925    /**
926     * Takes a one-dimensional list, returns the list with the appropriate items converted to a human readable string
927     * @param data
928     * @return
929     */
930    def renderDatesHumanReadable(data) {
931        def tmpDateContainer = []
932        data. each {
933            if(it instanceof Number) {
934                try{
935                    tmpDateContainer << new java.util.Date( (Long) it ).toString()
936                } catch(IllegalArgumentException e){
937                    tmpDateContainer << it
938                }
939            } else {
940                tmpDateContainer << it // To handle items such as 'unknown'
941            }
942        }
943        return tmpDateContainer
944    }
945        /**
946         * Returns a closure for the given entitytype that determines the value for a criterion
947         * on the given object. The closure receives two parameters: the sample and a field.
948         *
949         * For example:
950         *              How can one retrieve the value for subject.name, given a sample? This can be done by
951         *              returning the field values sample.parentSubject:
952         *                      { sample, field -> return getFieldValue( sample.parentSubject, field ) }
953         * @return      Closure that retrieves the value for a field and the given field
954         */
955        protected Closure valueCallback( String entity ) {
956                switch( entity ) {
957                        case "Study":
958                        case "studies":
959                                return { sample, field -> return getFieldValue( sample.parent, field ) }
960                        case "Subject":
961                        case "subjects":
962                                return { sample, field -> return getFieldValue( sample.parentSubject, field ); }
963                        case "Sample":
964                        case "samples":
965                                return { sample, field -> return getFieldValue( sample, field ) }
966                        case "Event":
967                        case "events":
968                                return { sample, field ->
969                                        if( !sample || !sample.parentEventGroup || !sample.parentEventGroup.events || sample.parentEventGroup.events.size() == 0 )
970                                                return null
971
972                                        return sample.parentEventGroup.events?.collect { getFieldValue( it, field ) };
973                                }
974                        case "EventGroup":
975                        case "eventGroups":
976                                return { sample, field ->
977                                        if( !sample || !sample.parentEventGroup )
978                                                return null
979
980                                        // For eventgroups only the name is supported
981                                        if( field == "name" )
982                                                return sample.parentEventGroup.name
983                                        else
984                                                return null 
985                                }
986       
987                        case "SamplingEvent":
988                        case "samplingEvents":
989                                return { sample, field -> return getFieldValue( sample.parentEvent, field ); }
990                        case "Assay":
991                        case "assays":
992                                return { sample, field ->
993                                        def sampleAssays = Assay.findByParent( sample.parent ).findAll { it.samples?.contains( sample ) };
994                                        if( sampleAssays && sampleAssays.size() > 0 )
995                                                return sampleAssays.collect { getFieldValue( it, field ) }
996                                        else
997                                                return null
998                                }
999                }
1000        }
1001       
1002        /**
1003        * Returns the domain object that should be used with the given entity string
1004        *
1005        * For example:
1006        *               What object should be consulted if the user asks for "studies"
1007        *               Response: Study
1008        * @return       Domain object that should be used with the given entity string
1009        */
1010   protected def domainObjectCallback( String entity ) {
1011           switch( entity ) {
1012                   case "Study":
1013                   case "studies":
1014                           return Study
1015                   case "Subject":
1016                   case "subjects":
1017                           return Subject
1018                   case "Sample":
1019                   case "samples":
1020                           return Sample
1021                   case "Event":
1022                   case "events":
1023                        return Event
1024                   case "SamplingEvent":
1025                   case "samplingEvents":
1026                           return SamplingEvent
1027                   case "Assay":
1028                   case "assays":
1029                                return Assay
1030                   case "EventGroup":
1031                   case "eventGroups":
1032                                   return EventGroup
1033               
1034           }
1035   }
1036
1037    /**
1038    * Returns the objects within the given study that should be used with the given entity string
1039    *
1040    * For example:
1041    *           What object should be consulted if the user asks for "samples"
1042    *           Response: study.samples
1043    * @return   List of domain objects that should be used with the given entity string
1044    */
1045    protected def templateObjectCallback( String entity, Study study ) {
1046      switch( entity ) {
1047          case "Study":
1048          case "studies":
1049              return study
1050          case "Subject":
1051          case "subjects":
1052              return study?.subjects
1053          case "Sample":
1054          case "samples":
1055              return study?.samples
1056          case "Event":
1057          case "events":
1058               return study?.events
1059          case "SamplingEvent":
1060          case "samplingEvents":
1061              return study?.samplingEvents
1062          case "Assay":
1063          case "assays":
1064                  return study?.assays
1065      }
1066    }
1067       
1068        /**
1069         * Computes the mean value and Standard Error of the mean (SEM) for the given values
1070         * @param values        List of values to compute the mean and SEM for. Strings and null
1071         *                                      values are ignored
1072         * @return                      Map with two keys: 'value' and 'error'
1073         */
1074        protected Map computeMeanAndError( values ) {
1075                // TODO: Handle the case that one of the values is a list. In that case,
1076                // all values should be taken into account.     
1077                def mean = computeMean( values );
1078                def error = computeSEM( values, mean );
1079               
1080                return [ 
1081                        "value": mean,
1082                        "error": error
1083                ]
1084        }
1085       
1086        /**
1087         * Computes the mean of the given values. Values that can not be parsed to a number
1088         * are ignored. If no values are given, null is returned.
1089         * @param values        List of values to compute the mean for
1090         * @return                      Arithmetic mean of the values
1091         */
1092        protected def computeMean( List values ) {
1093                def sumOfValues = 0;
1094                def sizeOfValues = 0;
1095                values.each { value ->
1096                        def num = getNumericValue( value );
1097                        if( num != null ) {
1098                                sumOfValues += num;
1099                                sizeOfValues++
1100                        }
1101                }
1102
1103                if( sizeOfValues > 0 )
1104                        return sumOfValues / sizeOfValues;
1105                else
1106                        return null;
1107        }
1108
1109        /**
1110        * Computes the standard error of mean of the given values. 
1111        * Values that can not be parsed to a number are ignored. 
1112        * If no values are given, null is returned.
1113        * @param values         List of values to compute the standard deviation for
1114        * @param mean           Mean of the list (if already computed). If not given, the mean
1115        *                                       will be computed using the computeMean method
1116        * @return                       Standard error of the mean of the values or 0 if no values can be used.
1117        */
1118    protected def computeSEM( List values, def mean = null ) {
1119       if( mean == null )
1120            mean = computeMean( values )
1121
1122       def sumOfDifferences = 0;
1123       def sizeOfValues = 0;
1124       values.each { value ->
1125           def num = getNumericValue( value );
1126           if( num != null ) {
1127               sumOfDifferences += Math.pow( num - mean, 2 );
1128               sizeOfValues++
1129           }
1130       }
1131
1132       if( sizeOfValues > 0 ) {
1133           def std = Math.sqrt( sumOfDifferences / sizeOfValues );
1134           return std / Math.sqrt( sizeOfValues );
1135       } else {
1136           return null;
1137       }
1138    }
1139
1140    /**
1141         * Computes the median of the given values. Values that can not be parsed to a number
1142         * are ignored. If no values are given, null is returned.
1143         * @param values        List of values to compute the median for
1144         * @return                      Median of the values
1145         */
1146        protected def computeMedian( List values ) {
1147                def listOfValues = [];
1148                values.each { value ->
1149                        def num = getNumericValue( value );
1150                        if( num != null ) {
1151                                listOfValues << num;
1152                        }
1153                }
1154
1155        listOfValues.sort();
1156
1157        def listSize = listOfValues.size();
1158
1159        def objReturn = null;
1160
1161                if( listSize > 0 ) {
1162            def listHalf = (int) Math.abs(listSize/2);
1163            if(listSize%2==0) {
1164                // If the list is of an even size, take the mean of the middle two value's
1165                objReturn = (listOfValues.get(listHalf)+listOfValues.get(listHalf-1))/2;
1166            } else {
1167                // If the list is of an odd size, take the middle value
1168                objReturn = listOfValues.get(listHalf);
1169            }
1170        }
1171
1172                return ["value": objReturn];
1173        }
1174
1175    /**
1176         * Computes the count of the given values. Values that can not be parsed to a number
1177         * are ignored. If no values are given, null is returned.
1178         * @param values        List of values to compute the count for
1179         * @return                      Count of the values
1180         */
1181        protected def computeCount( List values ) {
1182                def sumOfValues = 0;
1183                def sizeOfValues = 0;
1184                values.each { value ->
1185                        def num = getNumericValue( value );
1186                        if( num != null ) {
1187                                sumOfValues += num;
1188                                sizeOfValues++
1189                        }
1190                }
1191
1192                if( sizeOfValues > 0 )
1193                        return ["value": sizeOfValues];
1194                else
1195                        return ["value": null];
1196        }
1197
1198    /**
1199         * Computes the sum of the given values. Values that can not be parsed to a number
1200         * are ignored. If no values are given, null is returned.
1201         * @param values        List of values to compute the sum for
1202         * @return                      Arithmetic sum of the values
1203         */
1204        protected def computeSum( List values ) {
1205                def sumOfValues = 0;
1206                def sizeOfValues = 0;
1207                values.each { value ->
1208                        def num = getNumericValue( value );
1209                        if( num != null ) {
1210                                sumOfValues += num;
1211                                sizeOfValues++
1212                        }
1213                }
1214
1215                if( sizeOfValues > 0 )
1216                        return ["value": sumOfValues];
1217                else
1218                        return ["value": null];
1219        }
1220   
1221        /**
1222         * Return the numeric value of the given object, or null if no numeric value could be returned
1223         * @param       value   Object to return the value for
1224         * @return                      Number that represents the given value
1225         */
1226        protected Number getNumericValue( value ) {
1227                // TODO: handle special types of values
1228                if( value instanceof Number ) {
1229                        return value;
1230                } else if( value instanceof RelTime ) {
1231                        return value.value;
1232                }
1233               
1234                return null
1235        }
1236
1237        /** 
1238         * Returns a field for a given templateentity
1239         * @param object        TemplateEntity (or subclass) to retrieve data for
1240         * @param fieldName     Name of the field to return data for.
1241         * @return                      Value of the field or null if the value could not be retrieved
1242         */
1243        protected def getFieldValue( TemplateEntity object, String fieldName ) {
1244                if( !object || !fieldName )
1245                        return null;
1246               
1247                try {
1248                        return object.getFieldValue( fieldName );
1249                } catch( Exception e ) {
1250                        return null;
1251                }
1252        }
1253
1254        /**
1255         * Parses a fieldId that has been created earlier by createFieldId
1256         * @param fieldId       FieldId to parse
1257         * @return                      Map with attributes of the selected field. Keys are 'name', 'id', 'source' and 'type'
1258         * @see createFieldId
1259         */
1260        protected Map parseFieldId( String fieldId ) {
1261                def attrs = [:]
1262
1263                if( !fieldId )
1264                        return null;
1265               
1266                def parts = fieldId.split(",",5)
1267               
1268                attrs = [
1269                        "id": new String(parts[ 0 ].decodeBase64()),
1270                        "name": new String(parts[ 1 ].decodeBase64()),
1271                        "source": new String(parts[ 2 ].decodeBase64()),
1272                        "type": new String(parts[ 3 ].decodeBase64()),
1273            "unit": parts.length>4? new String(parts[ 4 ].decodeBase64()) : null,
1274                        "fieldId": fieldId
1275                ]
1276
1277        return attrs
1278        }
1279       
1280        /**
1281         * Returns a string representation of the given fieldType, which can be sent to the userinterface
1282         * @param fieldType     CATEGORICALDATA, DATE, RELTIME, NUMERICALDATA
1283         * @return      String representation
1284         */
1285        protected String dataTypeString( fieldType ) {
1286                return (fieldType==CATEGORICALDATA || fieldType==DATE || fieldType==RELTIME ? "categorical" : "numerical")
1287        }
1288       
1289        /**
1290         * Create a fieldId based on the given attributes
1291         * @param attrs         Map of attributes for this field. Keys may be 'name', 'id', 'source' and 'type'
1292         * @return                      Unique field ID for these parameters
1293         * @see parseFieldId
1294         */
1295        protected String createFieldId( Map attrs ) {
1296                // TODO: What if one of the attributes contains a comma?
1297                def name = attrs.name.toString();
1298                def id = (attrs.id ?: name).toString();
1299                def source = attrs.source.toString();
1300                def type = (attrs.type ?: "").toString();
1301        def unit = (attrs.unit ?: "").toString();
1302
1303                return id.bytes.encodeBase64().toString() + "," +
1304                name.bytes.encodeBase64().toString() + "," +
1305                source.bytes.encodeBase64().toString() + "," +
1306                type.bytes.encodeBase64().toString() + "," +
1307                unit.bytes.encodeBase64().toString();
1308        }
1309
1310    /**
1311     * Set the response code and an error message
1312     * @param code HTTP status code
1313     * @param msg Error message, string
1314     */
1315    protected void returnError(code, msg){
1316        response.sendError(code , msg)
1317    }
1318
1319    /**
1320     * Determines what type of data a field contains
1321     * @param studyId An id that can be used with Study.get/1 to retrieve a study from the database
1322     * @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
1323     * @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
1324     * @return Either CATEGORICALDATA, NUMERICALDATA, DATE or RELTIME
1325     */
1326    protected int determineFieldType(studyId, fieldId, inputData = null){
1327                def parsedField = parseFieldId( fieldId );
1328        def study = Study.get(studyId)
1329                def data = []
1330               
1331                // If the fieldId is incorrect, or the field is not asked for, return
1332                // CATEGORICALDATA
1333                if( !parsedField )
1334                        return CATEGORICALDATA;
1335
1336        try{
1337            if( parsedField.source == "GSCF" ) {
1338                if(parsedField.id.isNumber()){
1339                        return determineCategoryFromTemplateFieldId(parsedField.id)
1340                } else { // Domainfield or memberclass
1341                    def callback = domainObjectCallback( parsedField.type )
1342                                       
1343                    // 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
1344                    if(callback.metaClass.methods.contains( "giveDomainFields" ) && callback?.giveDomainFields()?.name?.contains(parsedField.name.toString())){
1345                        // Use the associated templateField to determine the field type
1346                        return determineCategoryFromTemplateField(
1347                                callback?.giveDomainFields()[
1348                                    callback?.giveDomainFields().name.indexOf(parsedField.name.toString())
1349                                ]
1350                        )
1351                    }
1352                    // Apparently it is not a templatefield as well as a memberclass
1353
1354                    def field = callback?.declaredFields.find { it.name == parsedField.name };
1355                    if( field ) {
1356                        return determineCategoryFromClass( field.getType() )
1357                    } else {
1358                        // TODO: how do we communicate this to the user? Do we allow the process to proceed?
1359                        log.error( "The user asked for field " + parsedField.type + " - " + parsedField.name + ", but it doesn't exist." );
1360                    }
1361                }
1362            } else {
1363                if(inputData == null){ // If we did not get data, we need to request it from the module first
1364                    data = getModuleData( study, study.getSamples(), parsedField.source, parsedField.name );
1365                    return determineCategoryFromData(data)
1366                } else {
1367                    return determineCategoryFromData(inputData)
1368                }
1369            }
1370        } catch(Exception e){
1371            log.error("VisualizationController: determineFieldType: "+e)
1372            e.printStackTrace()
1373            // If we cannot figure out what kind of a datatype a piece of data is, we treat it as categorical data
1374            return CATEGORICALDATA
1375        }
1376    }
1377
1378    /**
1379     * Determines a field category, based on the input parameter 'classObject', which is an instance of type 'class'
1380     * @param classObject
1381     * @return Either CATEGORICALDATA of NUMERICALDATA
1382     */
1383    protected int determineCategoryFromClass(classObject){
1384        log.trace "Determine category from class: "+classObject+", of class: "+classObject?.class
1385        if(classObject==java.lang.String){
1386            return CATEGORICALDATA
1387        } else {
1388            return NUMERICALDATA
1389        }
1390    }
1391
1392    /**
1393     * 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.
1394     * @param inputObject Either a single item, or a collection of items
1395     * @return Either CATEGORICALDATA of NUMERICALDATA
1396     */
1397    protected int determineCategoryFromData(inputObject){
1398        def results = []
1399               
1400        if(inputObject instanceof Collection){
1401            // This data is more complex than a single value, so we will call ourselves again so we c
1402            inputObject.each {
1403                                if( it != null )
1404                        results << determineCategoryFromData(it)
1405            }
1406        } else {
1407                        // Unfortunately, the JSON null object doesn't resolve to false or equals null. For that reason, we
1408                        // exclude those objects explicitly here.
1409                        if( inputObject != null && inputObject?.class != org.codehaus.groovy.grails.web.json.JSONObject$Null ) {
1410                    if(inputObject.toString().isDouble()){
1411                        results << NUMERICALDATA
1412                    } else {
1413                        results << CATEGORICALDATA
1414                    }
1415                        }
1416        }
1417
1418        results.unique()
1419
1420        if(results.size() > 1) {
1421            // If we cannot figure out what kind of a datatype a piece of data is, we treat it as categorical data
1422            results[0] = CATEGORICALDATA
1423        } else if( results.size() == 0 ) {
1424                        // If the list is empty, return the numerical type. If it is the only value, if will
1425                        // be discarded later on. If there are more entries (e.g part of a collection)
1426                        // the values will be regarded as numerical, if the other values are numerical 
1427                        results[ 0 ] = NUMERICALDATA
1428        }
1429
1430                return results[0]
1431    }
1432
1433    /**
1434     * Determines a field category, based on the TemplateFieldId of a Templatefield
1435     * @param id A database ID for a TemplateField
1436     * @return Either CATEGORICALDATA of NUMERICALDATA
1437     */
1438    protected int determineCategoryFromTemplateFieldId(id){
1439        TemplateField tf = TemplateField.get(id)
1440        return determineCategoryFromTemplateField(tf)
1441    }
1442
1443    /**
1444     * Determines a field category, based on the TemplateFieldType of a Templatefield
1445     * @param id A database ID for a TemplateField
1446     * @return Either CATEGORICALDATA of NUMERICALDATA
1447     */
1448    protected int determineCategoryFromTemplateField(tf){
1449        if(tf.type==TemplateFieldType.DOUBLE || tf.type==TemplateFieldType.LONG){
1450            log.trace "GSCF templatefield: NUMERICALDATA ("+NUMERICALDATA+") (based on "+tf.type+")"
1451            return NUMERICALDATA
1452        }
1453        if(tf.type==TemplateFieldType.DATE){
1454            log.trace "GSCF templatefield: DATE ("+DATE+") (based on "+tf.type+")"
1455            return DATE
1456        }
1457        if(tf.type==TemplateFieldType.RELTIME){
1458            log.trace "GSCF templatefield: RELTIME ("+RELTIME+") (based on "+tf.type+")"
1459            return RELTIME
1460        }
1461        log.trace "GSCF templatefield: CATEGORICALDATA ("+CATEGORICALDATA+") (based on "+tf.type+")"
1462        return CATEGORICALDATA
1463    }
1464    /**
1465     * 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.
1466     * @param returnData The object containing the data
1467     * @return results A JSON object
1468     */
1469    protected void sendResults(returnData){
1470        def results = [:]
1471        if(infoMessage.size()!=0){
1472            results.put("infoMessage", infoMessage)
1473            infoMessage = []
1474        }
1475        results.put("returnData", returnData)
1476        render results as JSON
1477    }
1478
1479    /**
1480     * Properly formats an informational message that will be returned to the client. Resets the informational message to the empty String.
1481     * @param returnData The object containing the data
1482     * @return results A JSON object
1483     */
1484    protected void sendInfoMessage(){
1485        def results = [:]
1486        results.put("infoMessage", infoMessage)
1487        infoMessage = []
1488        render results as JSON
1489    }
1490
1491    /**
1492     * Adds a new message to the infoMessage
1493     * @param message The information that needs to be added to the infoMessage
1494     */
1495    protected void setInfoMessage(message){
1496        infoMessage.add(message)
1497        log.trace "setInfoMessage: "+infoMessage
1498    }
1499
1500    /**
1501     * Adds a message to the infoMessage that gives the client information about offline modules
1502     */
1503    protected void setInfoMessageOfflineModules(){
1504        infoMessageOfflineModules.unique()
1505        if(infoMessageOfflineModules.size()>0){
1506            String message = "Unfortunately"
1507            infoMessageOfflineModules.eachWithIndex{ it, index ->
1508                if(index==(infoMessageOfflineModules.size()-2)){
1509                    message += ', the '+it+' and '
1510                } else {
1511                    if(index==(infoMessageOfflineModules.size()-1)){
1512                        message += ' the '+it
1513                    } else {
1514                        message += ', the '+it
1515                    }
1516                }
1517            }
1518            message += " could not be reached. As a result, we cannot at this time visualize data contained in "
1519            if(infoMessageOfflineModules.size()>1){
1520                message += "these modules."
1521            } else {
1522                message += "this module."
1523            }
1524            setInfoMessage(message)
1525        }
1526        infoMessageOfflineModules = []
1527    }
1528
1529    /**
1530     * Combine several blocks of formatted data into one. These blocks have been formatted by the formatData function.
1531     * @param inputData Contains a list of maps, of the following format
1532     *          - a key 'series' containing a list, that contains one or more maps, which contain the following:
1533     *            - a key 'name', containing, for example, a feature name or field name
1534     *            - a key 'y', containing a list of y-values
1535     *            - a key 'error', containing a list of, for example, standard deviation or standard error of the mean values,
1536     */
1537    protected def formatCategoryData(inputData){
1538        // NOTE: This function is no longer up to date with the current inputData layout.
1539        def series = []
1540        inputData.eachWithIndex { it, i ->
1541            series << ['name': it['yaxis']['title'], 'y': it['series']['y'][0], 'error': it['series']['error'][0]]
1542        }
1543        def ret = [:]
1544        ret.put('type', inputData[0]['type'])
1545        ret.put('x', inputData[0]['x'])
1546        ret.put('yaxis',['title': 'title', 'unit': ''])
1547        ret.put('xaxis', inputData[0]['xaxis'])
1548        ret.put('series', series)
1549        return ret
1550    }
1551
1552    /**
1553     * Given two objects of either CATEGORICALDATA or NUMERICALDATA
1554     * @param rowType The type of the data that has been selected for the row, either CATEGORICALDATA or NUMERICALDATA
1555     * @param columnType The type of the data that has been selected for the column, either CATEGORICALDATA or NUMERICALDATA
1556     * @return
1557     */
1558    protected def determineVisualizationTypes(rowType, columnType){
1559                def types = []
1560               
1561        if(rowType == CATEGORICALDATA || rowType == DATE || rowType == RELTIME){
1562            if(columnType == CATEGORICALDATA || columnType == DATE || columnType == RELTIME){
1563                                types = [ [ "id": "table", "name": "Table"] ];
1564            } else {    // NUMERICALDATA
1565                types = [ [ "id": "horizontal_barchart", "name": "Horizontal barchart"] ];
1566            }
1567        } else {        // NUMERICALDATA
1568            if(columnType == CATEGORICALDATA || columnType == DATE || columnType == RELTIME){
1569                types = [ [ "id": "barchart", "name": "Barchart"], [ "id": "linechart", "name": "Linechart"] ];
1570            } else {
1571                types = [ [ "id": "scatterplot", "name": "Scatterplot"], [ "id": "linechart", "name": "Linechart"] ];
1572            }
1573        }
1574        return types
1575    }
1576       
1577        /**
1578        * Returns the types of aggregation possible for the given two objects of either CATEGORICALDATA or NUMERICALDATA
1579        * @param rowType The type of the data that has been selected for the row, either CATEGORICALDATA or NUMERICALDATA
1580        * @param columnType The type of the data that has been selected for the column, either CATEGORICALDATA or NUMERICALDATA
1581        * @param groupType The type of the data that has been selected for the grouping, either CATEGORICALDATA or NUMERICALDATA
1582        * @return
1583        */
1584        protected def determineAggregationTypes(rowType, columnType, groupType = null ){
1585                // A list of all aggregation types. By default, every item is possible
1586                def types = [
1587                        [ "id": "average", "name": "Average", "disabled": false ],
1588                        [ "id": "count", "name": "Count", "disabled": false ],
1589                        [ "id": "median", "name": "Median", "disabled": false ],
1590                        [ "id": "none", "name": "No aggregation", "disabled": false ],
1591                        [ "id": "sum", "name": "Sum", "disabled": false ],
1592                ]
1593
1594                // Normally, all aggregation types are possible, with three exceptions:
1595                //              Categorical data on both axes. In that case, we don't have anything to aggregate, so we can only count
1596                //              Grouping on a numerical field is not possible. In that case, it is ignored
1597                //                      Grouping on a numerical field with categorical data on both axes (table) enabled aggregation,
1598                //                      In that case we can aggregate on the numerical field.
1599               
1600                if(rowType == CATEGORICALDATA || rowType == DATE || rowType == RELTIME){
1601                        if(columnType == CATEGORICALDATA || columnType == DATE || columnType == RELTIME){
1602                               
1603                                if( groupType == NUMERICALDATA ) {
1604                                        // Disable 'none', since that can not be visualized
1605                                        types.each {
1606                                                if( it.id == "none" )
1607                                                        it.disabled = true
1608                                        }
1609                                } else {
1610                                        // Disable everything but 'count'
1611                                        types.each { 
1612                                                if( it.id != "count" ) 
1613                                                        it.disabled = true
1614                                        }
1615                                }
1616                        }
1617                }
1618               
1619                return types
1620   }
1621}
Note: See TracBrowser for help on using the repository browser.