Lurch core classes

source

connection.js


import { MathConcept } from './math-concept.js'

/**
 * The Connection class is a thin API to make it easy to access connection data
 * stored in {@link MathConcept MathConcept} instances.  Specifically, the
 * construction and destruction of Connection objects has nothing to do with the
 * actual connections that exist among nodes in a {@link MathConcept MathConcept}
 * hierarchy, but rather these objects are created for convenience in accessing
 * and modifying those connections.
 * 
 * Specifically, the data for a connection between two MathConcepts is stored as
 * follows.  Assume we have MathConcepts X and Y with IDs idX and idY,
 * respectively, and a connection from X to Y that has ID C and associated data
 * D (which is an arbitrary Map stored as JSON).  This information is stored by
 * the following convention.
 * 
 *  * MathConcept X will have an attribute with key "_conn target C" and value
 *    idY.
 *  * MathConcept Y will have an attribute with key "_conn source C" and value
 *    idX.
 *  * MathConcept X will have an attribute with key "_conn data C" and value D.
 * 
 * In each of the above examples, the literal value C is not in the attribute
 * key, but rather the ID of the connection we're referring to as C.
 * 
 * While it would be possible to create, edit, and/or remove such data with just
 * the attribute getters and setters on {@link MathConcept MathConcept} instances,
 * this would be very inconvenient.  It will produce cleaner code if we treat
 * connections as first-class citizens by creating this class.  But its
 * instances merely hide the details of manipulating the data as described
 * above; they do not store their own data.  In particular:
 * 
 *  * We might have multiple instances of the Connection class all in existence
 *    at once, all referring to the exact same connection.  This does not make
 *    multiple copies of the connection; it merely gives us multiple ways to
 *    refer to it.
 *  * We might have no instances of the Connection class in existence for some
 *    particular connection.  This does not make the connection go away; it
 *    merely means we don't currently have objects that refer to it.
 */
export class Connection {

    /**
     * Each Connection should have a globally unique ID, and there should be a
     * dictionary of such IDs that give us a way to create a Connection instance
     * with that ID.
     * 
     * This Map stores the association of Connection IDs (keys) with MathConcept
     * instances (values), such that the MathConcept `IDs[x]` is the source of the
     * Connection with ID `x`.
     * 
     * This data structure should not be accessed by clients; it is private to
     * this class.  Use {@link Connection.withID withID()} instead.
     * 
     * @see {@link Connection.withID withID()}
     */
    static IDs = new Map

    /**
     * Create a Connection instance representing the connection with the given
     * ID.  Keep in mind that connections among MathConcepts are stored as data
     * within those MathConcepts, and this class is merely a convenient API for
     * manipulating that data.  So a connection exists independently of how many
     * (zero or more) Connection instances exist in memory.  If you want to
     * query or manipulate a connection, it is handy to get a Connection
     * instance for it, using this function.
     * 
     * @param {string} id - The globally unique ID of a connection among
     *   MathConcept instances
     * @return {Connection} A Connection instance representing the connection
     *   whose ID was given, or undefined if the given ID is not in the
     *   {@link Connection.IDs IDs} mapping
     */
    static withID ( id ) {
        return Connection.IDs.has( id ) ? new Connection( id ) : undefined
    }

    /**
     * This function constructs a new instance, and in that sense behaves very
     * much like {@link Connection.withID withID()}, but that function is to be
     * preferred over this one for two reasons.
     * 
     *  1. This function always returns a Connection instance, since it is the
     *     constructor, even if the parameter passed is not the ID of any
     *     existing Connection.  The resulting Connection instance will not be
     *     of any use to the client.
     *  2. Calling a constructor gives the illusion that the connection object
     *     is forming a connection between two {@link MathConcept MathConcept}s,
     *     while the {@link Connection.withID withID()} function suggests the
     *     truth more accurately, that we are simply getting ahold of an
     *     already-existing connection.  To form a new connection between two
     *     MathConcepts, use the {@link Connection.create create()} function
     *     instead.
     * 
     * @param {string} id - The globally unique ID of a connection among
     *   MathConcept instances
     * @see {@link Connection.withID withID()}
     * @see {@link Connection.create create()}
     */
    constructor ( id ) { this.id = id }

    /**
     * Create a new connection between two {@link MathConcept MathConcept}
     * instances.  This writes data into the attributes of those two instances,
     * data representing the connection, and then returns a Connection instance
     * that gives convenient access to that data.
     * 
     * @param {string} connectionID - The ID to be used for the newly formed
     *   connection.  This must not already exist in the
     *   {@link Connection.IDs IDs} mapping; if it does, this function takes no
     *   action.
     * @param {string} sourceID - The ID of the MathConcept that should be the
     *   source of the new connection.  If no MathConcept has this ID, this
     *   function takes no action.
     * @param {string} targetID - The ID of the MathConcept that should be the
     *   target of the new connection.  If no MathConcept has this ID, this
     *   function takes no action.
     * @param {*} [data] - Optional data to be assigned to the newly formed
     *   connection.  This parameter will be passed directly to the
     *   {@link Connection#attr attr()} function; see its documentation for the
     *   acceptable types and their meanings.
     * @return {Connection} A Connection instance for the newly created
     *   connection between the source and target.  This return value can be
     *   safely ignored, because the connection data is stored in the source and
     *   target MathConcepts, and is not dependent on the Connection object
     *   itself.  But this return value will be false if any step in the
     *   process fails, including if the connection ID is already in use or the
     *   source or target IDs are invalid.
     */
    static create ( connectionID, sourceID, targetID, data = null ) {
        if ( Connection.IDs.has( connectionID ) ) return false
        const source = MathConcept.instanceWithID( sourceID )
        if ( !source ) return false
        const target = MathConcept.instanceWithID( targetID )
        if ( !target ) return false
        source.setAttribute( `_conn target ${connectionID}`, targetID )
        target.setAttribute( `_conn source ${connectionID}`, sourceID )
        Connection.IDs.set( connectionID, source )
        const result = new Connection( connectionID )
        if ( data ) result.attr( data )
        return result
    }

    /**
     * Get the source {@link MathConcept MathConcept} for this connection.  This is
     * taken directly from the {@link Connection.IDs IDs} mapping.
     * 
     * @return {MathConcept} The source {@link MathConcept MathConcept}
     * @see {@link Connection#target target()}
     */
    source () { return Connection.IDs.get( this.id ) }

    /**
     * Get the target {@link MathConcept MathConcept} for this connection.
     * 
     * @return {MathConcept} The target {@link MathConcept MathConcept}
     * @see {@link Connection#source source()}
     */
    target () {
        const source = this.source()
        if ( !source ) return undefined
        const targetID = source.getAttribute( `_conn target ${this.id}` )
        if ( !targetID ) return undefined
        return MathConcept.instanceWithID( targetID )
    }

    /**
     * Get the JSON data associated with this connection.  Such data is stored
     * in the source {@link MathConcept MathConcept}, as documented in the
     * conventions described for {@link Connection this class}.
     * 
     * @private
     * @return {Object} The JSON data stored in the connection data attribute
     *   of the source {@link MathConcept MathConcept}, as long as it is an object.
     *   If it is not, this function returns a new, empty object instead, to
     *   guarantee that its return value is an object.  The return value will be
     *   undefined only if this object has no
     *   {@link Connection#source source()}.
     */
    _getData () {
        const source = this.source()
        if ( !source ) return undefined
        const result = source.getAttribute( `_conn data ${this.id}` )
        return result instanceof Object ? result : { }
    }

    /**
     * Set the JSON data associated with this connection.  Such data is stored
     * in the source {@link MathConcept MathConcept}, as documented in the
     * conventions described for {@link Connection this class}.
     * 
     * @private
     * @param {Object} json - A JavaScript object amenable to JSON encoding,
     *   holding the key-value pairs that are to be the data associated with
     *   this connection.
     * @return {boolean} True if and only if it succeeded in writing the data
     *   into the source {@link MathConcept MathConcept}.  This fails only if this
     *   object has no {@link Connection#source source()}.
     */
    _setData ( json ) {
        const source = this.source()
        if ( !source ) return false
        source.setAttribute( `_conn data ${this.id}`, json )
        return true
    }

    /**
     * This function is equivalent to zero or more calls to the
     * {@link Connection#setAttribute setAttribute()} function in immediate
     * succession, except that they are combined into a single modification of
     * the source MathConcept, to minimize the number of events generated.
     * 
     * @param {*} attributes - If this is an array, it is treated as an array of
     *   key-value pairs, and we use each such pair to update the data for this
     *   connection.  If this is a Map, then all of its key-value pairs are used
     *   instead, in the same way.  If this is any other kind of JavaScript
     *   object, then all of its keys and values are used instead, in the same
     *   way.
     * @return {boolean} True if and only if it succeeded in writing the data
     *   into the source {@link MathConcept MathConcept}.  This fails only if this
     *   object has no {@link Connection#source source()}.
     * @see {@link Connection#setAttribute setAttribute()}
     */
    attr ( attributes = [ ] ) {
        const newData = JSON.copy( this._getData() )
        if ( attributes instanceof Array ) {
            for ( const [ key, value ] of attributes ) {
                newData[key] = value
            }
        } else if ( attributes instanceof Map ) {
            for ( const [ key, value ] of attributes ) {
                newData[key] = value
            }
        } else if ( attributes instanceof Object ) {
            for ( const key of Object.keys( attributes ) ) {
                newData[key] = attributes[key]
            }
        }
        return this._setData( newData )
    }

    /**
     * Look up a value in the data associated with this connection.  The data
     * for a connection is a set of key-value pairs, stored in the source
     * {@link MathConcept MathConcept} for the connection, as described in the
     * conventions documented at the top of {@link Connection this class}.
     * This function looks up one value in that set of key-value pairs, given a
     * key.
     * 
     * @param {string} key - The key for the attribute to look up
     * @return {*} The value corresponding to the given key
     * @see {@link Connection#setAttribute setAttribute()}
     * @see {@link Connection#getAttributeKeys getAttributeKeys()}
     */
    getAttribute ( key ) {
        const data = this._getData()
        return data ? data[key] : undefined
    }

    /**
     * Store a value in the data associated with this connection.  The data
     * for a connection is a set of key-value pairs, stored in the source
     * {@link MathConcept MathConcept} for the connection, as described in the
     * conventions documented at the top of {@link Connection this class}.
     * This function adds or overwrites a pair in that set, given the new key
     * and value to use.  This therefore generates one
     * {@link MathConcept#willBeChanged willBeChanged} event and one
     * {@link MathConcept#wasChanged wasChanged} event in the source
     * {@link MathConcept MathConcept}.
     * 
     * @param {string} key - The key for the attribute to add or change
     * @param {*} value - The new value to associate with the key.  This should
     *   be data that is amenable to JSON encoding.
     * @return {boolean} True if and only if it succeeded in writing the data
     *   into the source {@link MathConcept MathConcept}.  This fails only if this
     *   object has no {@link Connection#source source()}.
     * @see {@link Connection#getAttribute getAttribute()}
     * @see {@link Connection#getAttributeKeys getAttributeKeys()}
     * @see {@link Connection#attr attr()}
     */
    setAttribute ( key, value ) {
        const newData = JSON.copy( this._getData() )
        newData[key] = value
        return this._setData( newData )
    }

    /**
     * The list of keys for all attributes of this connection.  The data for a
     * connection is a set of key-value pairs, stored in the source
     * {@link MathConcept MathConcept} for the connection, as described in the
     * conventions documented at the top of {@link Connection this class}.
     * This function looks up all the keys in that data and returns them.
     * 
     * @return {string[]} A list of strings, each one a key.  If this Connection
     *   instance has an invalid ID or any other misconfiguration, this result
     *   will be undefined.
     * @see {@link Connection#getAttribute getAttribute()}
     * @see {@link Connection#setAttribute setAttribute()}
     */
    getAttributeKeys () {
        const data = this._getData()
        return data ? Object.keys( data ) : undefined
    }

    /**
     * Check whether a key exists in the data associated with this connection.
     * The data for a connection is a set of key-value pairs, stored in the
     * source {@link MathConcept MathConcept} for the connection, as described in
     * the conventions documented at the top of {@link Connection this class}.
     * This function looks up a key and returns whether or not that key is
     * present in the data.
     * 
     * @param {string} key - The key to look up
     * @return {boolean} True if the key exists in the data, false if it does
     *   not or if there is any other problem with the lookup (such as this
     *   instance's ID being invalid)
     * @see {@link Connection#setAttribute setAttribute()}
     * @see {@link Connection#getAttribute getAttribute()}
     */
    hasAttribute ( key ) { return this._getData().hasOwnProperty( key ) }

    /**
     * Remove zero or more key-value pairs from the data associated with this
     * connection.  The data for a connection is a set of key-value pairs,
     * stored in the source {@link MathConcept MathConcept} for the connection, as
     * described in the conventions documented at the top of
     * {@link Connection this class}.  This function removes zero or more pairs
     * from that set, based on the list of keys it is given.  It makes only one
     * call to the {@link MathConcept#setAttribute setAttribute()} function in the
     * source {@link MathConcept MathConcept}, therefore generating only one
     * {@link MathConcept#willBeChanged willBeChanged} event and one
     * {@link MathConcept#wasChanged wasChanged} event in that
     * {@link MathConcept MathConcept}.
     * 
     * @param {string[]} keys - The keys to remove
     * @return {boolean} True if and only if it succeeded in altering the data
     *   in the source {@link MathConcept MathConcept}.  This fails only if this
     *   object has no {@link Connection#source source()}.
     * @see {@link Connection#setAttribute setAttribute()}
     * @see {@link Connection#getAttributeKeys getAttributeKeys()}
     */
    clearAttributes ( ...keys ) {
        const newData = JSON.copy( this._getData() )
        for ( const key of keys ) delete newData[key]
        return this._setData( newData )
    }

    /**
     * Delete a new connection between two {@link MathConcept MathConcept}
     * instances.  This deletes data from the attributes of those two instances,
     * data representing the connection, and thus generates one
     * {@link MathConcept#willBeChanged willBeChanged} event and one
     * {@link MathConcept#wasChanged wasChanged} event in each.  It also removes
     * this Connection's ID from {@link Connection.IDs the global mapping}.
     * 
     * @return {boolean} True if and only if it succeeded in deleting the data
     *   in the source and target {@link MathConcept MathConcept}s.  This fails only
     *   if this object has no {@link Connection#source source()} or no
     *   {@link Connection#target target()}.
     */
    remove () {
        const source = this.source()
        const target = this.target()
        if ( source )
            source.clearAttributes( `_conn target ${this.id}`,
                                    `_conn data ${this.id}` )
        if ( target )
            target.clearAttributes( `_conn source ${this.id}` )
        Connection.IDs.delete( this.id )
        return !!source && !!target
    }

    /**
     * When replacing a MathConcept in a hierarchy with another, we often want to
     * transfer all connections that went into or out of the old MathConcept to
     * its replacement instead.  This function performs that task.
     * 
     * @param {MathConcept} giver - The MathConcept that will lose its connections
     * @param {MathConcept} receiver - The MathConcept that will gain them
     * @return {boolean} Whether the operation succeeds, which happens as long
     *   as the receiver has a tracked ID and all relevant connections can be
     *   successfully removed from one place and recreated in another
     * @see {@link MathConcept#transferConnectionsTo transferConnectionsTo()}
     */
    static transferConnections ( giver, receiver ) {
        if ( !receiver.idIsTracked() ) return false
        for ( const connection of giver.getConnections() ) {
            // find connection source; replace giver -> receiver if needed
            let source = connection.source()
            if ( !source ) continue
            if ( source == giver ) source = receiver
            // find connection target; replace giver -> receiver if needed
            let target = connection.target()
            if ( !target ) continue
            if ( target == giver ) target = receiver
            // find connection data; if there is none, make it null
            let data = connection._getData()
            if ( Object.keys( data ).length == 0 ) data = null
            // delete the old and add the new
            if ( !connection.remove() ) return false
            if ( !Connection.create( connection.id, source.ID(),
                                     target.ID(), data ) ) return false
        }
        return true
    }

    /**
     * When a MathConcept is undergoing a change of ID, if it is part of any
     * connection, that connection will need to update how it is stored in the
     * attributes of either the source or target, to stay consistent with the
     * MathConcept's change of ID.
     * 
     * This function can be called when a MathConcept's ID changes, and if that
     * MathConcept exists on either end of this connection, it will update the
     * data on the other end to stay in sync with the ID change.
     * 
     * Clients should never need to call this; it is for use exclusively by the
     * {@link MathConcept MathConcept} class.
     * 
     * @param {string} oldID - The old ID of the changing MathConcept
     * @param {string} newID - The new ID of the changing MathConcept
     * @see {@link Connection#changeTargetID changeTargetID()}
     * @see {@link MathConcept#changeID changeID()}
     */
    handleIDChange ( oldID, newID ) {
        const source = this.source()
        const target = this.target()
        if ( !source || !target ) return
        if ( source.ID() == oldID )
            target.setAttribute( `_conn source ${this.id}`, newID )
        if ( target.ID() == oldID )
            source.setAttribute( `_conn target ${this.id}`, newID )
    }

}