Asynchronous loading of the JS Map API when scrolling the page

Open in CodeSandbox

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
        <script crossorigin src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>

        <script
            data-plugins="transform-modules-umd"
            data-presets="react, typescript"
            type="text/babel"
            src="./common.ts"
        ></script>
        <script
            data-plugins="transform-modules-umd"
            data-presets="react, typescript"
            type="text/babel"
            src="../variables.ts"
        ></script>
        <script data-plugins="transform-modules-umd" data-presets="typescript" type="text/babel">
            import {loadMapScript} from './common';
            import {LOCATION} from '../variables';
            
            window.map = null;
            interface InfoMessageProps {
                text: string;
            }
            
            // Flag that indicates whether the JS Map API is currently being loaded
            let isLoading = false;
            let checked = false;
            
            // Function to create the map after initializing the JS Map API
            async function createMap() {
                // Waiting for all api elements to be loaded
                await mappable.ready;
                const {MMap, MMapDefaultSchemeLayer, MMapControl, MMapControls} = mappable;
            
                // Wait for the api to load to access the entity system (MMapComplexEntity)
                class InfoMessageClass extends mappable.MMapComplexEntity<InfoMessageProps> {
                    private _element!: HTMLDivElement;
                    private _detachDom!: () => void;
            
                    // Method for create a DOM control element
                    _createElement(props: InfoMessageProps) {
                        // Create a root element
                        const infoWindow = document.createElement('div');
                        infoWindow.classList.add('info_window');
                        infoWindow.innerHTML = props.text;
            
                        return infoWindow;
                    }
            
                    // Method for attaching the control to the map
                    _onAttach() {
                        this._element = this._createElement(this._props);
                        this._detachDom = mappable.useDomContext(this, this._element, this._element);
                    }
            
                    // Method for detaching control from the map
                    _onDetach() {
                        this._detachDom();
                        this._detachDom = undefined;
                        this._element = undefined;
                    }
                }
            
                // Initialize the map
                map = new MMap(
                    // Pass the link to the HTMLElement of the container
                    document.getElementById('map'),
                    // Pass the map initialization parameters
                    {location: LOCATION, showScaleInCopyrights: true},
                    // Add a map scheme layer
                    [new MMapDefaultSchemeLayer({})]
                );
            
                map.addChild(
                    new MMapControls({position: 'top left'}).addChild(
                        new MMapControl().addChild(
                            new InfoMessageClass({text: 'The map is loaded only after it appears in the viewport'})
                        )
                    )
                );
            }
            
            // Function to toggle the visibility of the loading spinner
            function toggleLoader() {
                document.querySelector('.loader-container').classList.toggle('_hide', !isLoading);
            }
            
            // Function to async loading of JS Map API script.
            async function fetchScript() {
                try {
                    // Set loading flag to true and show the loading spinner
                    isLoading = true;
                    toggleLoader();
            
                    // Load the JS Map API script
                    await loadMapScript(checked);
                } catch (error) {
                    // Log any errors that occur during script loading
                    console.error(error);
                    return 'error';
                } finally {
                    // Set loading flag to false and hide the loading spinner
                    isLoading = false;
                    toggleLoader();
                }
            }
            
            function scrollToMap() {
                const scrollableContent = document.getElementById('container');
                scrollableContent.scrollTo({
                    behavior: 'smooth',
                    top: scrollableContent.scrollHeight
                });
            }
            
            function handleSwitch() {
                checked = !checked;
                (document.getElementById('switch') as HTMLInputElement).checked = checked;
            }
            
            // Event handler for tab change
            function handleScroll() {
                const scrollableContent = document.getElementById('container');
                const isVisible = scrollableContent.scrollTop + scrollableContent.clientHeight >= scrollableContent.scrollHeight;
            
                if (isVisible && typeof mappable === 'undefined') {
                    // If the JS Map API is currently loading, then do nothing
                    if (isLoading) return;
            
                    // If mappable is not defined, fetch the script and create the map
                    if (typeof mappable === 'undefined') {
                        fetchScript().then(() => {
                            createMap();
                        });
            
                        return;
                    }
            
                    // If the map is not yet created, create it
                    !map && createMap();
                }
            }
            
            // Add event listeners for scroll
            document.getElementById('container').addEventListener('scroll', handleScroll);
            document.getElementById('button').addEventListener('click', scrollToMap);
            document.getElementById('switch').addEventListener('change', handleSwitch);
        </script>

        <style> html, body, #app { width: 100%; height: 100%; margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; } .toolbar { position: absolute; z-index: 1000; top: 0; left: 0; display: flex; align-items: center; padding: 16px; } .toolbar a { padding: 16px; }  </style>
        <link rel="stylesheet" href="./common.css" />
        <link rel="stylesheet" href="../variables.css" />
    </head>
    <body>
        <div class="container" id="container">
            <div class="inner_block">
                <div class="title__block">
                    <div class="title">
                        This your website
                        <br />
                        about some good place in your city
                    </div>
                    <div>
                        <button id="button" type="button" class="button">Scroll to map</button>
                    </div>
                </div>
                <div id="map" class="map__block">
                    <div class="loader-container _hide"><div class="loader"></div></div>
                </div>
                <div class="behaviorControl">
                    <div class="behaviorControl__block">
                        <div class="behaviorControl__title">Imitate bad internet</div>
                        <label class="behaviorControl__label">
                            <input id="switch" type="checkbox" />
                            <div class="behaviorControl__slider"></div>
                        </label>
                    </div>
                </div>
            </div>
        </div>
    </body>
</html>
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
        <script crossorigin src="https://cdn.jsdelivr.net/npm/react@17/umd/react.production.min.js"></script>
        <script crossorigin src="https://cdn.jsdelivr.net/npm/react-dom@17/umd/react-dom.production.min.js"></script>
        <script crossorigin src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>

        <script
            data-plugins="transform-modules-umd"
            data-presets="typescript"
            type="text/babel"
            src="../variables.ts"
        ></script>
        <script
            data-plugins="transform-modules-umd"
            data-presets="typescript"
            type="text/babel"
            src="./common.ts"
        ></script>
        <script data-plugins="transform-modules-umd" data-presets="react, typescript" type="text/babel">
            import {loadMapScript} from './common';
            import {LOCATION} from '../variables';
            import type TReact from 'react';
            
            window.map = null;
            
            main();
            
            async function main() {
                // Function for create a map component after initializing the JS Map API
                const createMapComponent = async () => {
                    // For each object in the JS API, there is a React counterpart
                    // To use the React version of the API, include the module @mappable-world/mappable-reactify
                    const [mappableReact] = await Promise.all([mappable.import('@mappable-world/mappable-reactify'), mappable.ready]);
                    const reactify = mappableReact.reactify.bindTo(React, ReactDOM);
                    const {MMap, MMapDefaultSchemeLayer, MMapControl, MMapControls} = reactify.module(mappable);
            
                    // Return a functional React component representing the map
                    return () => {
                        return (
                            // Initialize the map and pass initialization parameters
                            <MMap location={LOCATION} showScaleInCopyrights={true} ref={React.useCallback((x) => (map = x), [])}>
                                {/* Add a map scheme layer */}
                                <MMapDefaultSchemeLayer />
            
                                <MMapControls position="top left">
                                    <MMapControl>
                                        <div className="info_window">The map is loaded only after it appears in the viewport</div>
                                    </MMapControl>
                                </MMapControls>
                            </MMap>
                        );
                    };
                };
            
                // When the JS Map API is initialized, the Map variable will become a react component
                let Map: () => TReact.JSX.Element = null;
            
                // Loader component to display during JS Map API loading
                function Loader() {
                    return (
                        <div className="loader-container">
                            <div className="loader" />
                        </div>
                    );
                }
            
                function App() {
                    // Flag that indicates whether the JS Map API is currently being loaded
                    const [isLoading, setIsLoading] = React.useState(false);
                    const [checked, setChecked] = React.useState(false);
            
                    const loadMap = async (withDelay: boolean) => {
                        try {
                            // Set loading flag to true
                            setIsLoading(true);
                            // Load the JS Map API script
                            await loadMapScript(withDelay);
                            // Initialize the Map component
                            Map = await createMapComponent();
                        } catch (error) {
                            // Log any errors that occur during script loading
                            console.error(error);
                        } finally {
                            // Set loading flag to false
                            setIsLoading(false);
                        }
                    };
            
                    const scrollToMap = () => {
                        const scrollableContent = document.getElementById('container');
                        scrollableContent.scrollTo({
                            behavior: 'smooth',
                            top: scrollableContent.scrollHeight
                        });
                    };
            
                    const handleScroll = () => {
                        const scrollableContent = document.getElementById('container');
                        const isVisible =
                            scrollableContent.scrollTop + scrollableContent.clientHeight >= scrollableContent.scrollHeight;
                        if (isVisible && typeof mappable === 'undefined' && !isLoading) {
                            loadMap(checked);
                        }
                    };
            
                    const handleSwitch = () => {
                        setChecked(!checked);
                    };
            
                    React.useEffect(() => {
                        document.getElementById('container').addEventListener('scroll', handleScroll);
                        return () => {
                            document.getElementById('container').removeEventListener('scroll', handleScroll);
                        };
                    });
            
                    return (
                        <div className="container" id="container">
                            <div className="inner_block">
                                <div className="title__block">
                                    <div className="title">
                                        This your website
                                        <br />
                                        about some good place in your city
                                    </div>
                                    <div>
                                        <button type="button" className="button" onClick={scrollToMap}>
                                            Scroll to map
                                        </button>
                                    </div>
                                </div>
                                <div id="map" className="map__block">
                                    {/* Show loader while loading the JS Map API, or render the map component if available. */}
                                    {isLoading ? <Loader /> : Map && <Map />}
                                </div>
                                <div className="behaviorControl">
                                    <div className="behaviorControl__block">
                                        <div className="behaviorControl__title">Imitate bad internet</div>
                                        <label className="behaviorControl__label">
                                            <input type="checkbox" checked={checked} onChange={handleSwitch} />
                                            <div className="behaviorControl__slider"></div>
                                        </label>
                                    </div>
                                </div>
                            </div>
                        </div>
                    );
                }
            
                ReactDOM.render(
                    <React.StrictMode>
                        <App />
                    </React.StrictMode>,
                    document.getElementById('app')
                );
            }
        </script>

        <style> html, body, #app { width: 100%; height: 100%; margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; } .toolbar { position: absolute; z-index: 1000; top: 0; left: 0; display: flex; align-items: center; padding: 16px; } .toolbar a { padding: 16px; }  </style>
        <link rel="stylesheet" href="./common.css" />
        <link rel="stylesheet" href="../variables.css" />
    </head>
    <body>
        <div id="app"></div>
    </body>
</html>
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
        <script crossorigin src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
        <script crossorigin src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>

        <script
            data-plugins="transform-modules-umd"
            data-presets="typescript"
            type="text/babel"
            src="../variables.ts"
        ></script>
        <script
            data-plugins="transform-modules-umd"
            data-presets="typescript"
            type="text/babel"
            src="./common.ts"
        ></script>
        <script data-plugins="transform-modules-umd" data-presets="typescript" type="text/babel">
            import {loadMapScript} from './common';
            import {LOCATION} from '../variables';
            
            window.map = null;
            
            async function main() {
                const LoaderComponent = Vue.defineComponent({
                    template: `
                        <div class='loader-container'>
                            <div class='loader' />
                        </div>
                    `
                });
            
                function createMapComponent(withDelay: boolean) {
                    return Vue.defineAsyncComponent({
                        loader: () => {
                            return new Promise(async (resolve) => {
                                if (typeof mappable == 'undefined') {
                                    await loadMapScript(withDelay);
                                }
            
                                const [mappableVue] = await Promise.all([mappable.import('@mappable-world/mappable-vuefy'), mappable.ready]);
                                const vuefy = mappableVue.vuefy.bindTo(Vue);
                                const {MMap, MMapDefaultSchemeLayer, MMapControl, MMapControls} = vuefy.module(mappable);
            
                                resolve({
                                    // @ts-ignore
                                    setup() {
                                        const refMap = (ref) => {
                                            window.map = ref?.entity;
                                        };
                                        return {LOCATION, refMap};
                                    },
                                    components: {
                                        MMap,
                                        MMapDefaultSchemeLayer,
                                        MMapControls,
                                        MMapControl
                                    },
                                    template: `
                          <MMap :location="LOCATION" :showScaleInCopyrights="true" :ref="refMap">
                            <!-- Add a map scheme layer -->
                            <MMapDefaultSchemeLayer/>
            
                            <MMapControls position="top left">
                              <MMapControl>
                                <div class="info_window">
                                  The map is loaded only after it appears in the viewport
                                </div>
                              </MMapControl>
                            </MMapControls>
                          </MMap>
                        `
                                });
                            });
                        },
                        loadingComponent: LoaderComponent
                    });
                }
            
                const app = Vue.createApp({
                    setup(props, ctx) {
                        const checked = Vue.ref(false);
                        const mapBox = Vue.ref(null);
                        const MapComponent = Vue.shallowRef(null);
            
                        const handleScroll = () => {
                            const scrollableContent = document.getElementById('container');
                            const isVisible =
                                scrollableContent.scrollTop + scrollableContent.clientHeight >= scrollableContent.scrollHeight;
            
                            if (isVisible && typeof mappable === 'undefined' && !MapComponent.value) {
                                MapComponent.value = createMapComponent(checked.value);
                            }
                        };
            
                        const scrollToMap = () => {
                            const scrollableContent = document.getElementById('container');
                            scrollableContent.scrollTo({
                                behavior: 'smooth',
                                top: scrollableContent.scrollHeight
                            });
                        };
            
                        const handleSwitch = () => {
                            checked.value = !checked.value;
                        };
            
                        Vue.onMounted(() => {
                            document.getElementById('container').addEventListener('scroll', handleScroll);
                        });
            
                        Vue.onUnmounted(() => {
                            document.getElementById('container').removeEventListener('scroll', handleScroll);
                        });
                        return {
                            checked,
                            mapBox,
                            handleSwitch,
                            scrollToMap,
                            MapComponent
                        };
                    },
                    template: `
                  <div class="container" id="container">
                    <div class="inner_block">
                      <div class="title__block">
                        <div class="title">
                          This your website
                          <br/>
                          about some good place in your city
                        </div>
                        <div>
                          <button type="button" class="button" @click="scrollToMap">
                            Scroll to map
                          </button>
                        </div>
                      </div>
                      <div  id="map" class="map__block" ref="mapBox">
                        <!-- Show loader while loading the JS Map API, or render the map component if available. -->
                        <component :is="MapComponent"/>
                      </div>
                      <div class="behaviorControl">
                        <div class="behaviorControl__block">
                          <div class="behaviorControl__title">Imitate bad internet</div>
                          <label class="behaviorControl__label" @change="handleSwitch">
                          <input type="checkbox" :checked="checked"/>
                              <div class="behaviorControl__slider"/>
                          </label>
                        </div>
                      </div>
                    </div>
                  </div>
                    `
                });
                app.mount('#app');
            }
            main();
        </script>

        <style> html, body, #app { width: 100%; height: 100%; margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; } .toolbar { position: absolute; z-index: 1000; top: 0; left: 0; display: flex; align-items: center; padding: 16px; } .toolbar a { padding: 16px; }  </style>
        <link rel="stylesheet" href="./common.css" />
        <link rel="stylesheet" href="../variables.css" />
    </head>
    <body>
        <div id="app"></div>
    </body>
</html>
import type {MMapLocationRequest} from '@mappable-world/mappable-types';

export const LOCATION: MMapLocationRequest = {
    center: [55.3216, 25.231], // starting position [lng, lat]
    zoom: 15 // starting zoom
};
:root {
    --color-bg: rgba(49, 49, 51, 1);
    --interact-btn-accent: #eefd7d;
    --interact-btn-text-color: rgba(5, 13, 51, 1);
    --interact-action: #eefd7d;
    --circle-bg-image-url: url('./circle-yellow.svg');
}
// Function to async load the JS Map API script.
export function loadMapScript(withDelay: boolean) {
    return new Promise((resolve, reject) => {
        setTimeout(
            () => {
                const script = document.createElement('script');
                script.src = 'https://js.api.mappable.world/v3/?apikey=<YOUR_APIKEY>&lang=en_US';
                script.onload = resolve;
                script.onerror = reject;
                document.head.appendChild(script);
            },
            withDelay ? 3000 : null
        );
    });
}
body,
#app {
    display: flex;
    justify-content: center;
    align-items: center;
}

.container {
    height: 300px;
    width: 1020px;
    overflow: auto;
    margin: 0 auto;
    position: relative;
    box-sizing: border-box;
}

.inner_block {
    padding-right: 210px;
    padding-left: 210px;
    padding-bottom: 10px;
    background-color: var(--color-bg);
    background-image: var(--circle-bg-image-url);
    background-repeat: no-repeat;
    background-position: 100% 0;
}

.title {
    margin-top: 32px;
    color: #f2f5fa;
    font-size: 36px;
    line-height: 42px;
    font-weight: 700;
}

.info_window {
    padding: 8px 12px 8px 32px;
    border-radius: 8px;
    background-color: #313133;
    background-image: url('./info-icon.svg');
    background-position: 8px 50%;
    background-repeat: no-repeat;
    gap: 8px;
    color: #f2f5fa;
    font-size: 14px;
    line-height: 20px;
}

.button {
    width: 120px;
    height: 40px;
    border-radius: 8px;
    background-color: var(--interact-btn-accent);
    color: var(--interact-btn-text-color);
    font-size: 14px;
    font-weight: 500;
    text-align: center;
    align-content: center;
    cursor: pointer;
    border: none;
}

.title__block {
    gap: 32px;
    display: flex;
    flex-direction: column;
}

.map__block {
    display: block;
    height: 250px;
    margin-top: 87px;
    background-color: rgba(36, 41, 48, 1);
    border-radius: 12px;
    overflow: hidden;
}

.behaviorControl {
    position: absolute;
    top: 8px;
    right: 40px;
    width: 228px;
    height: 32px;
}

.behaviorControl__title {
    font-size: 16px;
    color: #050d33;
    font-weight: 500;
    line-height: 20px;
}

.behaviorControl__block {
    position: fixed;
    display: flex;
    justify-content: space-between;
    align-items: center;
    width: 228px;
    height: 32px;
    padding: 10px 16px;
    background-color: white;
    border-radius: 12px;
}

.behaviorControl__tooltip {
    font-size: 12px;
    line-height: 16px;
    color: #898a8f;
}

.behaviorControl__label {
    margin-left: auto;

    height: 22px;
    width: 40px;

    display: inline-block;
    position: relative;
}

.behaviorControl__label input {
    display: none;
}

.behaviorControl__slider {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    top: 0;

    cursor: pointer;
    border-radius: 22px;
    background-color: #ccc;
    transition: 0.4s;
}

.behaviorControl__slider:before {
    content: '';
    width: 16px;
    height: 16px;

    position: absolute;
    bottom: 3px;
    left: 3px;

    border-radius: 50%;
    background-color: #fff;
    transition: 0.4s;
}

.behaviorControl__label input:checked + .behaviorControl__slider {
    background-color: var(--interact-action);
}

.behaviorControl__label input:checked + .behaviorControl__slider:before {
    transform: translateX(18px);
}

.loader-container {
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
}

.loader-container._hide {
    display: none;
}

.loader {
    width: 30px;
    height: 30px;
    background-image: url('./loader.svg');
    background-repeat: no-repeat;
    background-position: 6px 6px;
    animation: spin 1s linear infinite;
}

@keyframes spin {
    0% {
        transform: rotate(0deg);
    }
    100% {
        transform: rotate(360deg);
    }
}