datastores/location/LocationDataStore.js

/**
 *  LocationDataStore - access/control the URL in browsers
 * 
 *  This code is licensed under the MIT License (MIT).
 *  
 *  Copyright 2020, 2021, 2022 Rolf Bagge, Janus B. Kristensen, CAVI,
 *  Center for Advanced Visualization and Interaction, Aarhus University
 *  
 *  Permission is hereby granted, free of charge, to any person obtaining a copy
 *  of this software and associated documentation files (the “Software”), to deal
 *  in the Software without restriction, including without limitation the rights 
 *  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 
 *  copies of the Software, and to permit persons to whom the Software is 
 *  furnished to do so, subject to the following conditions:
 *  
 *  The above copyright notice and this permission notice shall be included in 
 *  all copies or substantial portions of the Software.
 *  
 *  THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 
 *  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
 *  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 
 *  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 
 *  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 
 *  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 
 *  THE SOFTWARE.
 *  
 */

/**
 * A datastore that allows reading and writing the location, query and hash as data.
 * 
 * This datastore registers as the type "location".
 * 
 * Any field mapped to this datastore from any object will use the URL params as the
 * source of the data. For example mapping the field "useBirds" will allow reading the
 * xyz value of a ?fun=true&useBirds=xyz URL.
 * 
 * The special mapping "locationHash" allows reading the hash-postfix of the URL. For
 * example the "cats" from https://cavi.au.dk/#cats
 * 
 * Listening for changes to the mapped locationHash will notify the program on load
 * as well as if/when the hash changes.
 * 
 * This datastore has no options.
 * 
 * @memberOf Datastores
 */
class LocationDataStore extends DirectDatastore {
    constructor(name, options = {}) {
        super(name, options);

        this.deleteCallbacks = [];
    }

    isShared() {
        return false;
    }

    destroy() {
        this.deleteCallbacks.forEach((deleteCallback)=>{
            deleteCallback.delete();
        });
    }

    async init() {
        const self = this;

        this.deleteCallbacks.push(VarvEngine.registerEventCallback("disappeared", async (context)=> {
            if(LocationDataStore.DEBUG) {
                console.log("Saw disappeared UUID (LocationDataStore):", context.target);
            }
            // Do nothing?
        }));
        this.deleteCallbacks.push(VarvEngine.registerEventCallback("appeared", async (context)=> {
            if(LocationDataStore.DEBUG) {
                console.log("Saw appeared UUID (LocationDataStore):", context.target);
            }
            let mark = VarvPerformance.start();
            // Do nothing? Maybe call them like onload?
            VarvPerformance.stop("LocationDataStore.registerEventCallback.appeared", mark);
        }));
        
        window.addEventListener("hashchange", function locationHashChanged(){
            self.onHashChange();
        });
    }
    
    createBackingStore(concept, property) {
        const self = this;
        if (this.isPropertyMapped(concept,property)) return;
                
        let setter = (uuid, value) => {
            let mark = VarvPerformance.start();
            
            if (property.name==="locationHash"){
                if (decodeURIComponent(location.hash.substring(1))!==value){
                    location.hash = value;
                }
            } else {
                // This is a parameter/location change if it differs from current
                let urlParams = new URLSearchParams(window.location.search);
                if (urlParams.has(property.name)){
                    if (urlParams.get(property.name)!=value){
                        if (LocationDataStore.DEBUG) console.info("FIXME: Location datastore only supports getting, not setting URL paramters so far");                        
                    }
                }                
                return;
            }

            VarvPerformance.stop("LocationDataStore.setter", mark);
        };
        let getter = (uuid) => {
            let mark = VarvPerformance.start();
            if (property.name==="locationHash"){
                let result = decodeURIComponent(location.hash.substring(1));
                if (result === "") throw new Exception("Cannot get empty locationHash");
                VarvPerformance.stop("LocationDataStore.getter.hash", mark);
                return result;
            } else {
                let urlParams = new URLSearchParams(window.location.search);
                if (!urlParams.has(property.name)) throw new Exception("Cannot get property not present in URL");                
                VarvPerformance.stop("LocationDataStore.getter.argument", mark);
                return urlParams.get(property.name);
            }
        };
        property.addSetCallback(setter);
        property.addGetCallback(getter);

        // Check if concept already is mapped, if not, register it
        this.internalAddPropertyMapping(concept, property, {setter: setter, getter: getter});
    }    
    
    removeBackingStore(concept, property) {
        if (!this.isPropertyMapped(concept, property)){
            throw new Error('Cannot unmap property from memory because the property was not mapped: ' + concept + "." + property);
        }

        let trackingData = this.internalPropertyTrackingData(concept, property);
        property.removeSetCallback(trackingData.setter);
        property.removeGetCallback(trackingData.getter);
        
        this.internalRemovePropertyMapping(concept, property);
    }
    
    async onHashChange(){
        if (LocationDataStore.DEBUG) console.log("Location hash changed", location.hash);
        // For each of our mapped concepts
        for (let [conceptName, properties] of this.mappedConcepts.entries()){
            if (properties.has("locationHash")){
                let concept = VarvEngine.getConceptFromType(conceptName);
                let property = concept.getProperty("locationHash");                
                let value = decodeURIComponent(location.hash.substring(1));
                
                if (LocationDataStore.DEBUG) console.log("Firing location hash property set", property, value);
                let uuids = await VarvEngine.getAllUUIDsFromType(concept.name, true);
                uuids.forEach(async uuid=>{
                    await property.setValue(uuid, value);
                });
            }
        };
    }
    
    async loadBackingStore() {
        const self = this;
        
        if (LocationDataStore.DEBUG) console.info("LocationDataStore location is "+location);
        
        setTimeout(async function onLoadLocationTriggers(){
            if (location.hash){
                await self.onHashChange();
            }

            // On-load set the URL parameters too
            for (let [name,value] of new URLSearchParams(window.location.search).entries()){
                for (let [conceptName, properties] of self.mappedConcepts.entries()){
                    if (properties.has(name)){
                        let concept = VarvEngine.getConceptFromType(conceptName);
                        let property = concept.getProperty(name);                

                        if (LocationDataStore.DEBUG) console.log("Firing URL parameter property set", conceptName, concept, property, value);
                        let uuids = await VarvEngine.getAllUUIDsFromType(conceptName, false); // nulllointer if true?
                        uuids.forEach(async uuid=>{
                            await property.setValue(uuid, value);
                        });
                    }
                };
            }
            
            self.hasLoaded = true;
        },0);
    }
}
LocationDataStore.DEBUG = false;
window.LocationDataStore = LocationDataStore;

// Register default dom datastore
Datastore.registerDatastoreType("location", LocationDataStore);