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>

    <!-- prettier-ignore -->
    <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>

    <!-- prettier-ignore -->
    <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>

    <!-- prettier-ignore -->
    <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>
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);
  }
}
// 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
    );
  });
}
: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');
}
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
};