diff --git a/static/js/attribute_mapping.js b/static/js/attribute_mapping.js new file mode 100644 index 00000000..b426dab1 --- /dev/null +++ b/static/js/attribute_mapping.js @@ -0,0 +1,245 @@ +/** + * Visual Attribute Mapping — attribute discovery from graph_json. + */ +var attributeMapping = { + RESERVED_NODE_ATTRS: [ 'id', 'name', 'label', 'aliases', 'popup', 'k', 'parent' ], + RESERVED_EDGE_ATTRS: [ 'id', 'source', 'target', 'name', 'is_directed', 'popup', 'k' ], + + discoveredAttributes: null, + + // Which visual properties support which mapping types. + VISUAL_PROPERTIES: { + node: [ + { id: 'background-color', label: 'Node color', discrete: true, continuous: true }, + { id: 'width', label: 'Node size', discrete: false, continuous: true }, + { id: 'shape', label: 'Node shape', discrete: true, continuous: false } + ], + edge: [ + { id: 'line-color', label: 'Edge color', discrete: true, continuous: true }, + { id: 'width', label: 'Edge width', discrete: false, continuous: true }, + { id: 'line-style', label: 'Edge style', discrete: true, continuous: false } + ] + }, + + init: function () { + if ( typeof graph_json === 'undefined' || !graph_json ) { + this.discoveredAttributes = { nodes: {}, edges: {} }; + return; + } + this.discoveredAttributes = this.extractAttributes( graph_json ); + }, + + extractAttributes: function ( graphJson ) { + if ( !graphJson || !graphJson.elements ) { + return { nodes: {}, edges: {} }; + } + + return { + nodes: this._scanElements( graphJson.elements.nodes, this.RESERVED_NODE_ATTRS ), + edges: this._scanElements( graphJson.elements.edges, this.RESERVED_EDGE_ATTRS ) + }; + }, + + _scanElements: function ( elements, reserved ) { + var attrs = {}; + var self = this; + + _.each( elements || [], function ( el ) { + _.each( el.data || {}, function ( value, key ) { + if ( reserved.indexOf( key ) !== -1 ) { + return; + } + if ( !attrs[ key ] ) { + attrs[ key ] = { values: [] }; + } + if ( value !== null && value !== undefined && value !== '' ) { + attrs[ key ].values.push( value ); + } + } ); + } ); + + var result = {}; + _.each( attrs, function ( info, key ) { + result[ key ] = self._classifyAttribute( info.values ); + } ); + return result; + }, + + _classifyAttribute: function ( values ) { + var unique = _.uniq( values ); + var allNumeric = unique.length > 0 && _.every( unique, function ( v ) { + return !isNaN( parseFloat( v ) ) && isFinite( v ); + } ); + + if ( allNumeric ) { + var nums = _.map( unique, parseFloat ); + return { + type: 'numerical', + min: _.min( nums ), + max: _.max( nums ), + count: values.length + }; + } + + return { + type: 'categorical', + values: unique.sort(), + count: values.length + }; + }, + + bindPanelEvents: function () { + $( '#mapAttributesBtn' ).off( 'click' ).on( 'click', function ( e ) { + e.preventDefault(); + attributeMapping.openPanel(); + } ); + + $( '#backToLayoutEditorBtn' ).off( 'click' ).on( 'click', function ( e ) { + e.preventDefault(); + attributeMapping.closePanel(); + } ); + + this.bindFormEvents(); + }, + + bindFormEvents: function () { + // When element type (nodes / edges) changes, reset to discrete mapping + // and repopulate both dropdowns based on the new element type. + $( '#mappingElementType' ).off( 'change' ).on( 'change', function () { + $( '#mappingMappingType' ).val( 'discrete' ); + attributeMapping.populateAttributeDropdown(); + attributeMapping.populateVisualPropertyDropdown(); + } ); + + // When attribute changes, infer mapping type (continuous vs discrete) + // from the discovered attribute meta and refresh visual properties. + $( '#mappingAttribute' ).off( 'change' ).on( 'change', function () { + attributeMapping.syncMappingTypeFromAttribute(); + attributeMapping.populateVisualPropertyDropdown(); + } ); + + // When mapping type is manually changed, just refresh visual properties. + $( '#mappingMappingType' ).off( 'change' ).on( 'change', function () { + attributeMapping.populateVisualPropertyDropdown(); + } ); + }, + + getSelectedElementType: function () { + var elementType = $( '#mappingElementType' ).val(); + return elementType === 'edge' ? 'edge' : 'node'; + }, + + getSelectedMappingType: function () { + return $( '#mappingMappingType' ).val() === 'continuous' ? 'continuous' : 'discrete'; + }, + + getVisualPropertiesForSelection: function () { + var elementType = this.getSelectedElementType(); + var mappingType = this.getSelectedMappingType(); + var properties = this.VISUAL_PROPERTIES[ elementType ] || []; + + return _.filter( properties, function ( property ) { + return mappingType === 'continuous' ? property.continuous : property.discrete; + } ); + }, + + getAttributesForElementType: function ( elementType ) { + var discovered = this.discoveredAttributes || { nodes: {}, edges: {} }; + return elementType === 'edge' ? discovered.edges : discovered.nodes; + }, + + getSelectedAttributeMeta: function () { + var attributeName = $( '#mappingAttribute' ).val(); + if ( !attributeName ) { + return null; + } + + var attributeMap = this.getAttributesForElementType( this.getSelectedElementType() ); + return attributeMap[ attributeName ] || null; + }, + + syncMappingTypeFromAttribute: function () { + var meta = this.getSelectedAttributeMeta(); + if ( !meta ) { + this.populateVisualPropertyDropdown(); + return; + } + + var mappingType = meta.type === 'numerical' ? 'continuous' : 'discrete'; + $( '#mappingMappingType' ).val( mappingType ); + this.populateVisualPropertyDropdown(); + }, + + populateAttributeDropdown: function () { + var elementType = this.getSelectedElementType(); + var attributeMap = this.getAttributesForElementType( elementType ); + var attributeNames = _.sortBy( _.keys( attributeMap ) ); + var $select = $( '#mappingAttribute' ); + + $select.empty(); + $select.append( $( '