Множество точек

Open on CodeSandbox

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
    <script src="https://api-maps.yandex.ru/v3/?apikey=<YOUR_APIKEY>&lang=en_US" type="text/javascript"></script>
    <script src="./common.js"></script>

    <script>
      window.map = null;

      main();
      async function main() {
        await ymaps3.ready;
        const {
          YMap,
          YMapDefaultSchemeLayer,
          YMapFeatureDataSource,
          YMapControls,
          YMapMarker,
          YMapCollection,
          YMapLayer,
          YMapListener
        } = ymaps3;

        const {YMapZoomControl} = await ymaps3.import('@yandex/ymaps3-controls@0.0.1');

        const {YMapClusterer, clusterByGrid} = await ymaps3.import('@yandex/ymaps3-clusterer@0.0.1');

        let location = LOCATION,
          setLocation = (l) => {
            location = l;
            reconcile();
            updateUrl(count, location, mode, clusterSize);
          };

        let mode = MODE,
          setMode = (m) => {
            mode = m;
            if (mode === MODE_CLUSTERER) {
              if (collection.root === map) {
                map.removeChild(collection);
              }

              map.addChild(clustererI);
            } else {
              if (clustererI.root === map) {
                map.removeChild(clustererI);
              }

              map.addChild(collection);
            }

            shownView.innerText = '';
            reconcile();
            updateUrl(count, location, mode, clusterSize);
          };

        const reconcile = throttle(() => {
          if (mode === MODE_CLUSTERER) {
            clustererI.update({features: [...points]});
          } else {
            let visiblePoints = points;

            if (mode === MODE_REMOVE) {
              const bounds = map.bounds;
              visiblePoints = visiblePoints.filter((p) => isVisible(p, bounds));
              shownView.innerText = `Shown: ${visiblePoints.length}`;
            }

            visiblePoints.forEach((p, i) => {
              if (!p.imperative) {
                p.imperative = marker(visiblePoints[i]);
              }

              try {
                if (collection.children[i] !== p.imperative) {
                  if (p.imperative.parent === collection) {
                    collection.removeChild(p.imperative);
                  }
                  collection.addChild(p.imperative, i);
                }
              } catch (e) {}
            });

            collection.children.slice(visiblePoints.length).forEach((e) => collection.removeChild(e));
          }
        }, 100);
        let points = [];
        const setPoints = (ps) => {
          points = ps;

          reconcile();
        };

        let count = DEFAULT_COUNT;
        const setCount = async (c) => {
          count = c;
          const gen = getPointList(count, points);

          document.querySelector('.slow').classList.add('show');
          updateUrl(count, location, mode, clusterSize);

          let currentCount = count;

          do {
            const {value, done} = gen.next();

            if (done) {
              break;
            }

            await new Promise((resolve) => setTimeout(resolve, 0));

            if (currentCount !== count) {
              break;
            }

            setPoints(value);
          } while (true);

          document.querySelector('.slow').classList.remove('show');
        };

        let clusterSize = DEFAULT_CLUSTER_SIZE;
        let gridSizedMethod = clusterByGrid({gridSize: Math.pow(2, clusterSize)});
        const setClusterSize = (cs) => {
          clusterSize = cs;
          clustererI.update({method: clusterByGrid({gridSize: Math.pow(2, clusterSize)})});
          updateUrl(count, location, mode, clusterSize);
        };

        const marker = (p) =>
          new YMapMarker(
            {
              id: p.id + '-' + p.geometry.coordinates.toString(),
              source: 'marker-source',
              coordinates: p.geometry.coordinates
            },
            p.markerElement[mode] || p.markerElement[MODE_NONE]
          );

        const cluster = (coordinates, features) =>
          new YMapMarker(
            {
              id: `${features[0].id}-${features.length}`,
              coordinates: coordinates,
              source: 'marker-source'
            },
            circle(features.length)
          );

        const clustererI = new YMapClusterer({
          marker,
          cluster,
          method: gridSizedMethod,
          features: points
        });

        const collection = new YMapCollection();

        map = new YMap(document.getElementById('map'), {location, zoomRange: ZOOM_RANGE}, [
          new YMapDefaultSchemeLayer(),
          new YMapFeatureDataSource({id: 'marker-source'}),
          new YMapLayer({source: 'marker-source', type: 'markers'}),
          new YMapControls({position: 'right'}).addChild(new YMapZoomControl({})),
          collection
        ]);

        map.addChild(
          new YMapListener({
            onUpdate: ({location}) => setLocation(location),
            onResize: () => setLocation({center: map.center, zoom: map.zoom})
          })
        );

        ui(mode, setMode, count, setCount, clusterSize, setClusterSize);
      }

      function ui(mode, onSetMode, count, onSetCount, clusterSize, onSetClusterSize) {
        const toolbar = document.getElementById('toolbar');
        toolbar.classList.add('mode_' + mode);
        toolbar.classList.toggle('show-switchers', SHOW_MODE_SWITCHERS);

        startDrawFPS(canvasRef);

        let location = LOCATION;
        let setMode = (m) => {
          toolbar.classList.remove('mode_' + mode);
          mode = m;
          toolbar.classList.add('mode_' + mode);
          none.checked = mode === MODE_NONE;
          removeHidden.checked = mode === MODE_REMOVE;
          clusterer.checked = mode === MODE_CLUSTERER;

          onSetMode(mode);
        };
        let setCount = (c) => {
          count = c;
          countView.innerText = count;
          countRange.value = count;
          onSetCount(count);
        };
        let setClusterSize = (cs) => {
          clusterSize = cs;
          clusterSizeRange.value = clusterSize;
          inlineStyle.innerText = `:root {
                        --radius: ${(clusterSize / 3) * 20}px
                    }`;
          clusterSizeView.innerText = Math.pow(2, clusterSize);
          onSetClusterSize(clusterSize);
        };

        setMode(MODE);

        countRange.step = Math.round((POINTS_MAX - POINTS_MIN) / 5);
        countRange.min = POINTS_MIN;
        countRange.max = POINTS_MAX;
        setCount(DEFAULT_COUNT);

        clusterSizeRange.step = 1;
        clusterSizeRange.min = CLUSTER_SIZE_MIN;
        clusterSizeRange.max = CLUSTER_SIZE_MAX;
        setClusterSize(DEFAULT_CLUSTER_SIZE);

        none.addEventListener('change', () => {
          setMode(MODE_NONE);
        });

        removeHidden.addEventListener('change', () => {
          setMode(removeHidden.checked ? MODE_REMOVE : MODE_NONE);
        });

        clusterer.addEventListener('change', () => {
          setMode(clusterer.checked ? MODE_CLUSTERER : MODE_NONE);
        });

        countRange.addEventListener('change', (e) => {
          const cnt = +e.target.value;
          setCount(cnt - (cnt % (cnt > 1000 ? 1000 : 100)));
        });

        clusterSizeRange.addEventListener('change', (e) => {
          setClusterSize(+e.target.value);
        });
      }
    </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="./input.css" />
  </head>
  <body>
    <div id="app">
      <canvas class="fps" width="70" height="48" id="canvasRef"></canvas>
      <div id="toolbar" class="toolbar options">
        <div>
          <div id="noneBox" class="switchers" style="order: 0">
            <label class="form-check-label" for="none">Without optimizations</label>
            <input class="form-check-input" type="radio" role="switch" id="none" />
          </div>
          <div id="removeHiddenBox" class="switchers" style="order: 1">
            <label class="form-check-label" for="removeHidden">Remove hidden</label>
            <input class="form-check-input" type="radio" id="removeHidden" />
          </div>
          <div id="clustererBox" class="switchers" style="order: 2">
            <label class="form-check-label" for="clusterer">Clusterer</label>
            <input class="form-check-input" type="radio" role="switch" id="clusterer" />
          </div>
          <div class="counter" style="order: 3">
            <span class="icon"></span>
            <label for="countRange" class="form-label">
              Counts: <span id="countView"></span> <span id="shownView"></span>
            </label>
            <input-range id="countRange" />
          </div>
          <div id="clusterSizeRangeBox" class="counter" style="order: 4; display: none">
            <style id="inlineStyle"></style>
            <label for="clusterSizeRange" class="form-label">
              Cluster size: <span id="clusterSizeView"></span>
            </label>
            <input-range type="range" class="form-range" id="clusterSizeRange" />
          </div>
        </div>
      </div>
      <div class="slow">
        <div></div>
        <div></div>
        <div></div>
      </div>
      <div id="map"></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@18/umd/react.production.min.js"></script>
    <script crossorigin src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js"></script>
    <script crossorigin src="https://cdn.jsdelivr.net/npm/babel-standalone@6/babel.min.js"></script>
    <script src="https://api-maps.yandex.ru/v3/?apikey=<YOUR_APIKEY>&lang=en_US" type="text/javascript"></script>
    <script src="./common.js"></script>

    <script type="text/babel">
      window.map = null;

      // CustomElements events are broken in React <=19 https://github.com/facebook/react/issues/22888
      function useCustomElementSubscription(ref, event, callback) {
        React.useEffect(() => {
          if (!ref.current) {
            return;
          }
          ref.current.addEventListener(event, callback);
          return () => {
            ref.current.removeEventListener(event, callback);
          };
        });
      }

      main();
      async function main() {
        const [ymaps3React] = await Promise.all([ymaps3.import('@yandex/ymaps3-reactify'), ymaps3.ready]);
        const reactify = ymaps3React.reactify.bindTo(React, ReactDOM);
        const {
          YMap,
          YMapDefaultSchemeLayer,
          YMapFeatureDataSource,
          YMapControls,
          YMapMarker,
          YMapLayer,
          YMapListener
        } = reactify.module(ymaps3);

        const {useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo} = React;

        const {YMapZoomControl} = reactify.module(await ymaps3.import('@yandex/ymaps3-controls@0.0.1'));

        const {YMapClusterer, clusterByGrid} = reactify.module(await ymaps3.import('@yandex/ymaps3-clusterer@0.0.1'));

        ReactDOM.createRoot(document.getElementById('app')).render(
          <React.StrictMode>
            <App />
          </React.StrictMode>
        );

        function Slow() {
          return (
            <div className="slow show">
              <div />
              <div />
              <div />
            </div>
          );
        }

        function Switchers({mode, setMode}) {
          const onToggleNone = useCallback(() => setMode(MODE_NONE), []);

          const onToggleRemoveHidden = useCallback((e) => setMode(e.target.checked ? MODE_REMOVE : MODE_NONE), []);

          const onToggleClusterer = useCallback((e) => setMode(e.target.checked ? MODE_CLUSTERER : MODE_NONE), []);

          return (
            <React.Fragment>
              <div className="switchers" style={{order: 0}}>
                <label className="form-check-label" htmlFor="none">
                  Without optimizations
                </label>
                <input
                  className="form-check-input"
                  type="radio"
                  id="none"
                  checked={mode === MODE_NONE}
                  onChange={onToggleNone}
                />
              </div>
              <div className="switchers" style={{order: 1}}>
                <label className="form-check-label" htmlFor="removeHidden">
                  Remove hidden
                </label>
                <input
                  className="form-check-input"
                  type="radio"
                  id="removeHidden"
                  checked={mode === MODE_REMOVE}
                  onChange={onToggleRemoveHidden}
                />
              </div>
              <div className="switchers" style={{order: 2}}>
                <label className="form-check-label" htmlFor="clasterer">
                  Clusterer
                </label>
                <input
                  className="form-check-input"
                  type="radio"
                  id="clasterer"
                  checked={mode === MODE_CLUSTERER}
                  onChange={onToggleClusterer}
                />
              </div>
            </React.Fragment>
          );
        }

        function CountRange({count, mode, visiblePoints, setCount}) {
          const ref = useRef(null);

          useCustomElementSubscription(
            ref,
            'change',
            useCallback((e) => {
              const cnt = +e.target.value;
              setCount(cnt - (cnt % (cnt > 1000 ? 1000 : 100)));
            }, [])
          );

          return (
            <div class="counter">
              <label htmlFor="countRange" className="form-label">
                Counts: {count} {mode === MODE_REMOVE ? `Shown: ${visiblePoints.length}` : ''}
              </label>
              <input-range
                ref={ref}
                type="range"
                id="countRange"
                min={POINTS_MIN}
                max={POINTS_MAX}
                step={Math.round((POINTS_MAX - POINTS_MIN) / 5)}
                value={count}
              />
            </div>
          );
        }

        function ClustererOptions({mode, clusterSize, setClusterSize}) {
          const ref = useRef(null);

          useCustomElementSubscription(
            ref,
            'change',
            useCallback((e) => setClusterSize(+e.target.value), [])
          );

          return (
            <div id="clusterSizeRangeBox" class="counter" style={{display: 'none'}}>
              <style>
                {`:root {
                                    --radius: ${(clusterSize / 3) * 20}px
                                }`}
              </style>
              <label htmlFor="clusterSizeRange" className="form-label">
                Cluster size: {Math.pow(2, clusterSize)}
              </label>
              <input-range
                ref={ref}
                type="range"
                id="clusterSizeRange"
                min={CLUSTER_SIZE_MIN}
                max={CLUSTER_SIZE_MAX}
                step={1}
                value={clusterSize}
              />
            </div>
          );
        }

        function App() {
          const canvasRef = useRef(null);

          const [slow, toggleSlow] = useState(true);
          const [location, setLocation] = useState(LOCATION);
          const [modeSlow, setModeSlow] = useState(MODE);
          const [mode, setMode] = useState(MODE);
          const [count, setCount] = useState(DEFAULT_COUNT);
          const [points, setPoints] = useState([]);
          const debounceSetPoints = useMemo(() => debounce((...args) => setPoints(...args), 300), []);

          const [clusterSize, setClusterSize] = useState(DEFAULT_CLUSTER_SIZE);
          const [gridSizedMethod, setGridSizedMethod] = useState(() =>
            clusterByGrid({gridSize: Math.pow(2, clusterSize)})
          );

          useLayoutEffect(() => {
            startDrawFPS(canvasRef.current);
          }, []);

          useEffect(() => {
            updateUrl(count, location, mode, clusterSize);
          }, [count, location, mode, clusterSize]);

          useEffect(() => {
            setMode(modeSlow);
            toggleSlow(true);
            requestIdleCallback(() => toggleSlow(false));
          }, [modeSlow, count]);

          useEffect(() => {
            debounceSetPoints((points) => getPointListSync(count, points));
          }, [count]);

          useEffect(() => {
            setGridSizedMethod(clusterByGrid({gridSize: Math.pow(2, clusterSize)}));
          }, [clusterSize]);

          const onUpdate = useCallback(({location}) => setLocation(location), []);
          const onResize = useCallback(() => setLocation({center: map.center, zoom: map.zoom}), []);

          const marker = useCallback(
            (p) => (
              <YMapMarker
                key={p.id + '-' + p.geometry.coordinates.toString()}
                source="marker-source"
                coordinates={p.geometry.coordinates}
                markerElement={p.markerElement[mode] || p.markerElement[MODE_NONE]}
              />
            ),
            [mode]
          );

          const cluster = useCallback(
            (coordinates, features) => (
              <YMapMarker
                key={`${features[0].id}-${features.length}`}
                coordinates={coordinates}
                source="marker-source"
              >
                <div className="circle">
                  <div className="circle-content">
                    <span className="circle-text">{features.length}</span>
                  </div>
                </div>
              </YMapMarker>
            ),
            []
          );

          const bounds = useMemo(() => map && map.bounds, [location]);

          let visiblePoints = points;

          if (mode === MODE_REMOVE) {
            visiblePoints = visiblePoints.filter((p) => isVisible(p, bounds));
          }

          return (
            <React.Fragment>
              {slow && <Slow />}
              <canvas className="fps" width={70} height={48} ref={canvasRef} />
              <div
                className={['toolbar', 'options', SHOW_MODE_SWITCHERS ? 'show-switchers' : null, `mode_${mode}`]
                  .filter(Boolean)
                  .join(' ')}
              >
                <div>
                  <Switchers mode={modeSlow} setMode={setModeSlow} />
                  <CountRange count={count} mode={mode} visiblePoints={visiblePoints} setCount={setCount} />
                  <ClustererOptions mode={mode} clusterSize={clusterSize} setClusterSize={setClusterSize} />
                </div>
              </div>
              <YMap location={location} zoomRange={ZOOM_RANGE} ref={(x) => (map = x)}>
                <YMapListener onUpdate={onUpdate} onResize={onResize} />
                <YMapDefaultSchemeLayer />
                <YMapControls position="right">
                  <YMapZoomControl />
                </YMapControls>
                <YMapFeatureDataSource id="marker-source" />
                <YMapLayer source="marker-source" type="markers" />

                {mode !== MODE_CLUSTERER && bounds && visiblePoints && visiblePoints.map(marker)}

                {mode === MODE_CLUSTERER && (
                  <YMapClusterer marker={marker} cluster={cluster} method={gridSizedMethod} features={points} />
                )}
              </YMap>
            </React.Fragment>
          );
        }
      }
    </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="./input.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 src="https://api-maps.yandex.ru/v3/?apikey=<YOUR_APIKEY>&lang=en_US" type="text/javascript"></script>
    <script src="./common.js"></script>

    <script>
      window.map = null;

      main();
      async function main() {
        const [ymaps3Vue] = await Promise.all([ymaps3.import('@yandex/ymaps3-vuefy'), ymaps3.ready]);
        const vuefy = ymaps3Vue.vuefy.bindTo(Vue);
        const {
          YMap,
          YMapDefaultSchemeLayer,
          YMapFeatureDataSource,
          YMapControls,
          YMapMarker,
          YMapLayer,
          YMapListener
        } = vuefy.module(ymaps3);
        const {YMapZoomControl} = vuefy.module(await ymaps3.import('@yandex/ymaps3-controls@0.0.1'));
        const {YMapClusterer, clusterByGrid} = vuefy.module(await ymaps3.import('@yandex/ymaps3-clusterer@0.0.1'));

        const Slow = Vue.defineComponent({
          name: 'Slow',
          template: `<div className="slow show"><div /><div /><div /></div>`
        });
        const Switchers = Vue.defineComponent({
          name: 'Switchers',
          props: ['modelValue'],
          emits: ['update:modelValue'],
          setup(props, {emit}) {
            const value = Vue.computed({
              get() {
                return props.modelValue;
              },
              set(value) {
                emit('update:modelValue', value);
              }
            });
            return {value, MODE_NONE, MODE_REMOVE, MODE_CLUSTERER};
          },
          template: `
                        <div class="switchers" style="order: 0">
                            <label class="form-check-label" htmlFor="none">Without optimizations</label>
                            <input class="form-check-input" type="radio" id="none" v-model="value" :value="MODE_NONE" />
                        </div>
                        <div class="switchers" style="order: 1">
                            <label class="form-check-label" htmlFor="removeHidden">Remove hidden</label>
                            <input class="form-check-input" type="radio" id="removeHidden" v-model="value" :value="MODE_REMOVE" />
                        </div>
                        <div class="switchers" style="order: 2">
                            <label class="form-check-label" htmlFor="clusterer">Clusterer</label>
                            <input class="form-check-input" type="radio" id="clusterer" v-model="value" :value="MODE_CLUSTERER" />
                        </div>
                        <br />`
        });
        const CountRange = Vue.defineComponent({
          name: 'CountRange',
          props: ['count', 'visiblePointsCount'],
          emits: ['update:count'],
          setup(props, {emit}) {
            const value = Vue.computed({
              get() {
                return props.count;
              },
              set(value) {
                emit('update:count', value - (value % (value > 1000 ? 1000 : 100)));
              }
            });
            return {value, POINTS_MIN, POINTS_MAX};
          },
          template: `<div class="counter">
                        <label htmlFor="countRange" class="form-label">
                            Counts: {{count}} <template v-if="visiblePointsCount !== undefined">Shown: {{visiblePointsCount}}</template>
                        </label>
                        <input-range type="range" id="countRange" :min="POINTS_MIN" :max="POINTS_MAX" :step="Math.round((POINTS_MAX - POINTS_MIN) / 5)" v-model="value" />
                    </div>`
        });
        const ClustererOptions = Vue.defineComponent({
          name: 'ClustererOptions',
          props: ['clusterSize'],
          emits: ['update:clusterSize'],
          setup(props, {emit}) {
            const size = Vue.computed(() => Math.pow(2, props.clusterSize));
            const value = Vue.computed({
              get() {
                return props.clusterSize;
              },
              set(value) {
                emit('update:clusterSize', value);
              }
            });
            return {value, size, CLUSTER_SIZE_MIN, CLUSTER_SIZE_MAX};
          },
          template: `<div class="counter">
                        <label htmlFor="clusterSizeRange" class="form-label">Cluster size: {{size}}</label>
                        <input-range type="range" id="clusterSizeRange" :min="CLUSTER_SIZE_MIN" :max="CLUSTER_SIZE_MAX" step=1 v-model="value" />
                    </div>`
        });

        const app = Vue.createApp({
          components: {
            YMap,
            YMapDefaultSchemeLayer,
            YMapFeatureDataSource,
            YMapControls,
            YMapMarker,
            YMapLayer,
            YMapListener,
            YMapZoomControl,
            YMapClusterer,
            Slow,
            Switchers,
            CountRange,
            ClustererOptions
          },
          setup() {
            const canvasRef = Vue.ref(null);
            const slow = Vue.ref(true);
            const location = Vue.shallowRef(LOCATION);
            const mode = Vue.ref(MODE);
            const count = Vue.ref(DEFAULT_COUNT);
            const clusterSize = Vue.ref(DEFAULT_CLUSTER_SIZE);
            const points = Vue.shallowRef([]);

            const visiblePoints = Vue.computed(() => {
              if (mode.value === MODE_REMOVE && location.value.bounds) {
                return points.value.filter((p) => isVisible(p, location.value.bounds));
              }
              return points.value;
            });
            const visiblePointsCount = Vue.computed(() => {
              if (mode.value === MODE_REMOVE) {
                return visiblePoints.value.length;
              }
              return undefined;
            });
            const gridSizedMethod = Vue.computed(() => clusterByGrid({gridSize: Math.pow(2, clusterSize.value)}));

            const refMap = (ref) => {
              window.map = ref?.entity;
            };
            const onUpdate = (updated) => {
              location.value = updated.location;
            };
            Vue.onMounted(() => {
              startDrawFPS(canvasRef.value);
            });
            Vue.watch(
              [count, mode],
              () => {
                slow.value = true;
                requestIdleCallback(() => (slow.value = false));
              },
              {immediate: true}
            );
            Vue.watch(
              [count, location, mode, clusterSize],
              ([count, location, mode, clusterSize]) => updateUrl(count, location, mode, clusterSize),
              {immediate: true}
            );
            Vue.watch(
              clusterSize,
              (newClusterSize) => {
                inlineStyle.innerText = `:root {
                                    --radius: ${(newClusterSize / 3) * 20}px
                                }`;
              },
              {immediate: true}
            );

            const setPoints = (newPoints) => {
              points.value = newPoints();
            };
            const debounceSetPoints = debounce((...args) => setPoints(...args), 300);

            Vue.watch(
              count,
              (newCount) => {
                debounceSetPoints(() => getPointListSync(newCount, points.value));
              },
              {immediate: true}
            );
            return {
              LOCATION,
              SHOW_MODE_SWITCHERS,
              MODE_CLUSTERER,
              MODE_NONE,
              canvasRef,
              clusterSize,
              slow,
              mode,
              count,
              visiblePointsCount,
              visiblePoints,
              gridSizedMethod,
              refMap,
              onUpdate
            };
          },
          template: `
                        <Slow v-if="slow" />
                        <canvas class="fps" width="70" height="48" ref="canvasRef"></canvas>
                        <div id="toolbar" :class="'toolbar options' + (SHOW_MODE_SWITCHERS ? ' show-switchers' : '') + ' mode_' + mode">
                            <div>
                                <Switchers v-if="SHOW_MODE_SWITCHERS" v-model="mode" />
                                <CountRange v-model:count="count" :visiblePointsCount="visiblePointsCount" />
                                <ClustererOptions v-if="mode === MODE_CLUSTERER" v-model:clusterSize="clusterSize" />
                            </div>
                        </div>
                        <YMap :location="LOCATION" :ref="refMap">
                            <YMapListener :onUpdate="onUpdate" />
                            <YMapDefaultSchemeLayer />
                            <YMapControls position="right">
                                <YMapZoomControl></YMapZoomControl>
                            </YMapControls>
                            <YMapFeatureDataSource id="marker-source" />
                            <YMapLayer source="marker-source" type="markers" />
                            <template v-if="mode !== MODE_CLUSTERER">
                                <YMapMarker
                                    v-for="point in visiblePoints"
                                    :key="point.id + '-' + point.geometry.coordinates.toString()"
                                    source="marker-source"
                                    :coordinates="point.geometry.coordinates"
                                    :markerElement="point.markerElement[mode] || point.markerElement[MODE_NONE]" />
                            </template>
                            <YMapClusterer v-else :method="gridSizedMethod" :features="visiblePoints">
                                    <template #marker="{feature}">
                                        <YMapMarker
                                            :key="feature.geometry.coordinates.toString() + '-' + feature.id"
                                            source="marker-source"
                                            :coordinates="feature.geometry.coordinates"
                                            :markerElement="feature.markerElement[mode] || feature.markerElement[MODE_NONE]"
                                        />
                                    </template>
                                    <template #cluster="{coordinates, features}">
                                        <YMapMarker :coordinates="coordinates" source="marker-source">
                                            <div class="circle">
                                                <div class="circle-content">
                                                    <span class="circle-text">{{features.length}}</span>
                                                </div>
                                            </div>
                                        </YMapMarker>
                                    </template>
                                </YMapClusterer>
                        </YMap>`
        });
        app.mount('#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="./input.css" />
  </head>
  <body>
    <style id="inlineStyle"></style>
    <div id="app"></div>
  </body>
</html>
#map {
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;

  font-family: 'Yandex Sans Text', Arial, Helvetica, sans-serif;
}

:root {
  --radius: 40px;
  --point-border-color: #fff;
  --point-bg-color: #313133;
  --point-size: 20px;
  --toolbar-offset: 12px;
  --toolbar-shadow: 0 0 10px 0 #0000001a;
}

.options {
  background-color: #fff;
  border-radius: var(--toolbar-offset);
  box-shadow: var(--toolbar-shadow);
  top: var(--toolbar-offset);
  left: var(--toolbar-offset);
  padding: 0;
  font-size: 14px;
  font-weight: 500;
}

.options.show-switchers > div {
  display: flex;
  flex-direction: column;
}

.fps {
  position: absolute;
  right: var(--toolbar-offset);
  top: var(--toolbar-offset);
  z-index: 30000;
  background-color: #eefd7c;
  border-radius: var(--toolbar-offset);
  box-shadow: var(--toolbar-shadow);
  height: 48px;
}

.point {
  width: var(--point-size);
  height: var(--point-size);
  transform: translate(-50%, -50%);
  border-radius: 9px;
  border: 2px solid var(--point-border-color);
  background-color: var(--point-bg-color);
}

.point:after {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 6px;
  height: 6px;
  background-color: var(--point-border-color);
  border-radius: 2px;
  transform: translate(-50%, -50%);
}

.count {
  display: inline-block;
  padding: 0 8px;
}

.circle {
  position: relative;

  width: var(--radius, 20px);
  height: var(--radius, 20px);

  color: #f2f5fa;
  border: 2px solid #fff;
  border-radius: 50%;
  background-color: #313133;

  transform: translate(-50%, -50%);

  box-shadow: 0 0 1.891838788986206px 0 #5f698314;
}

.circle-content {
  position: absolute;
  top: 50%;
  left: 50%;

  display: flex;
  justify-content: center;
  align-items: center;

  width: 70%;
  height: 70%;

  border-radius: 50%;

  transform: translate3d(-50%, -50%, 0);
}

.circle-text {
  font-size: 0.9em;

  color: #fff;
}

.slow {
  display: inline-block;
  width: 80px;
  height: 80px;
  position: absolute;
  z-index: 2000;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  opacity: 0.8;
}

.slow div {
  display: inline-block;
  position: absolute;
  left: 8px;
  width: 16px;
  background: #fff;
  animation: slow 1.2s cubic-bezier(0, 0.5, 0.5, 1) infinite;
}

.slow div:nth-child(1) {
  left: 8px;
  animation-delay: -0.24s;
}

.slow div:nth-child(2) {
  left: 32px;
  animation-delay: -0.12s;
}

.slow div:nth-child(3) {
  left: 56px;
  animation-delay: 0s;
}

.slow {
  display: none;
}

.slow.show {
  display: block;
}

@keyframes slow {
  0% {
    top: 8px;
    height: 64px;
  }
  50%,
  100% {
    top: 24px;
    height: 32px;
  }
}

.counter {
  display: flex;
  align-items: center;
  font-weight: 500;
  padding: 0 16px;
  margin-top: 16px;
  margin-bottom: 16px;
}

.show-switchers .counter {
  font-size: 10px;
  color: #808187;
  margin-bottom: 4px;
  margin-top: 4px;
  position: relative;
  top: -6px;
}

.switchers {
  justify-content: space-between;
  display: flex;
  align-items: center;
  min-height: 47px;
  font-weight: 500;
  padding: 0 16px;
}

.counter label,
.switchers label {
  flex: 1;
}

.counter .icon {
  margin-right: 8px;
  width: 16px;
  height: 16px;
  background: url(./point.svg) no-repeat;
}

.toolbar.show-switchers .counter .icon,
.toolbar.mode_clusterer .counter .icon {
  display: none;
}

.counter label {
  margin-right: 8px;
  min-width: 112px;
}

.toolbar.mode_remove .counter label {
  min-width: 185px;
}

.toolbar:not(.show-switchers) .switchers {
  display: none;
}

.toolbar.mode_clusterer #clusterSizeRangeBox {
  display: flex !important;
}

.toolbar.mode_none .counter {
  order: 0 !important;
}

.toolbar.mode_remove .counter {
  order: 1 !important;
}

.toolbar.mode_clusterer .counter {
  order: 2 !important;
}

.toolbar.mode_clusterer.show-switchers {
  padding-bottom: 16px;
}
const POINTS_MIN = 200;
const POINTS_MAX = 30200;

const CLUSTER_SIZE_MIN = 6;
const CLUSTER_SIZE_MAX = 11;

const MODE_NONE = 'none';
const MODE_REMOVE = 'remove';
const MODE_CLUSTERER = 'clusterer';

const SEARCH_PARAMS = new URLSearchParams(window.location.search);
const FIXED_POINT = [37.623082, 55.75254];
const DELTA_LENGTH = SEARCH_PARAMS.get('delta') ? +SEARCH_PARAMS.get('delta') : 5;

const LOCATION = {
  center: SEARCH_PARAMS.get('center') ? SEARCH_PARAMS.get('center').split(',').map(Number) : FIXED_POINT,
  zoom: SEARCH_PARAMS.get('zoom') ? +SEARCH_PARAMS.get('zoom') : 7
};

const ZOOM_MIN = Math.max(SEARCH_PARAMS.get('zoomMin') ? +SEARCH_PARAMS.get('zoomMin') : 5, 5);
const ZOOM_MAX = Math.min(SEARCH_PARAMS.get('zoomMax') ? +SEARCH_PARAMS.get('zoomMax') : 19, 21);
const ZOOM_RANGE = {min: ZOOM_MIN, max: ZOOM_MAX};

const DEFAULT_COUNT = SEARCH_PARAMS.get('count') ? +SEARCH_PARAMS.get('count') : POINTS_MIN;
const DEFAULT_CLUSTER_SIZE = SEARCH_PARAMS.get('clusterSize') ? +SEARCH_PARAMS.get('clusterSize') : CLUSTER_SIZE_MIN;
const MODE = [MODE_NONE, MODE_REMOVE, MODE_CLUSTERER].includes(SEARCH_PARAMS.get('mode'))
  ? SEARCH_PARAMS.get('mode')
  : MODE_NONE;
const SHOW_MODE_SWITCHERS = Boolean(SEARCH_PARAMS.get('showMode'));

const MARKER_ELEMENT = document.createElement('div');
MARKER_ELEMENT.classList.add('point');

function getMarkerElement(i) {
  return MARKER_ELEMENT.cloneNode(true);
}

const CHUNK_SIZE = 1000;

/**
 * Generator returns a random set of count points around the center of the map
 * @param {number} count
 * @param {Array} cacheList
 */
function* getPointList(count, cacheList) {
  if (cacheList.length > count) {
    yield cacheList.slice(0, count);
    return;
  }

  const result = [...cacheList];

  for (let i = cacheList.length; i < count; i += 1) {
    result.push({
      type: 'Feature',
      id: i,
      geometry: {
        coordinates: getRandomPoint()
      },
      /**
       * Elements are divided into modes, since they cannot be used
       * both in the clusterer and in direct output at the same time
       */
      markerElement: {
        [MODE_NONE]: getMarkerElement(i),
        [MODE_CLUSTERER]: getMarkerElement(i)
      }
    });

    if (i % CHUNK_SIZE === 0) {
      yield result;
    }
  }

  yield result;
}

function getPointListSync(count, cacheList) {
  const gen = getPointList(count, cacheList);
  let result = [];

  do {
    const {value, done} = gen.next();
    if (done) {
      break;
    }
    result = value;
  } while (true);

  return result;
}

const seed = (s) => () => {
  s = Math.sin(s) * 10000;
  return s - Math.floor(s);
};

const rnd = seed(10000);

function getRandomPoint() {
  const [x, y] = LOCATION.center;
  return [x + (rnd() > 0.5 ? -1 : 1) * rnd() * DELTA_LENGTH * 2, y + (rnd() > 0.5 ? -1 : 1) * rnd() * DELTA_LENGTH];
}

function runFPSCounter(cb) {
  const times = [];
  const timeoutShow = 300;
  let lastTime = 0;

  function fpsCounter() {
    requestAnimationFrame(() => {
      const now = performance.now();
      while (times.length > 0 && times[0] <= now - 1000) {
        times.shift();
      }
      times.push(now);
      const fps = times.length;

      if (lastTime + timeoutShow < now) {
        cb(fps);
        lastTime = now;
      }

      fpsCounter();
    });
  }

  fpsCounter();
}

/**
 * Draws the current FPS(Frame Per Second) value on the canvas
 * @param {HTMLCanvasElement} canvas
 */
function startDrawFPS(canvas) {
  const ctx = canvas.getContext('2d');

  const dpr = window.devicePixelRatio || 1,
    bsr =
      ctx.webkitBackingStorePixelRatio ||
      ctx.mozBackingStorePixelRatio ||
      ctx.msBackingStorePixelRatio ||
      ctx.oBackingStorePixelRatio ||
      ctx.backingStorePixelRatio ||
      1;

  const width = canvas.width;
  const height = canvas.height;

  if (dpr !== bsr) {
    canvas.width = width * dpr;
    canvas.height = height * dpr;
    canvas.style.width = width + 'px';
    canvas.style.height = height + 'px';
    ctx.scale(dpr, dpr);
  }

  ctx.font = '500 14px Arial';

  runFPSCounter((fps) => {
    ctx.clearRect(0, 0, width, height);
    const widthText = ctx.measureText(fps + ' fps').width;
    ctx.fillText(fps + ' fps', width / 2 - widthText / 2, 29);
  });
}

/**
 * Returns a function that will only be called once for all of its calls in the delay period
 * @param {Function} cb
 * @param {number} delay
 * @returns {(function(...[*]): void)|*}
 */
function debounce(cb, delay) {
  let timer = 0;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => cb(...args), delay);
  };
}

function throttle(func, ms, ctx) {
  let isThrottled = false;
  let savedArgs = null;

  function wrapper() {
    if (isThrottled) {
      savedArgs = arguments;
      return;
    }

    func.apply(ctx, arguments);

    isThrottled = true;

    setTimeout(() => {
      isThrottled = false;
      if (savedArgs) {
        wrapper.apply(ctx, savedArgs);
        savedArgs = null;
      }
    }, ms);
  }

  return wrapper;
}

/**
 * Checks if the point is within the visible area of the map
 * @param {[number,number]} coordinates
 * @param {[[number,number], [number,number]]} bounds
 * @returns {boolean}
 */
function isVisible({geometry: {coordinates}}, bounds) {
  if (!bounds) {
    return true;
  }

  const [x, y] = coordinates;
  const [[x1, y1], [x2, y2]] = bounds;

  return x >= x1 && x <= x2 && y >= y2 && y <= y1;
}

const updateUrl = debounce((count, location, mode, clusterSize) => {
  SEARCH_PARAMS.set('clusterSize', clusterSize);
  SEARCH_PARAMS.set('count', count);
  SEARCH_PARAMS.set('center', location.center.map((c) => c.toFixed(10)).toString());
  SEARCH_PARAMS.set('zoom', location.zoom.toFixed(0));
  SEARCH_PARAMS.set('mode', mode);
  const newRelativePathQuery = window.location.pathname + '?' + SEARCH_PARAMS.toString();
  history.replaceState(null, '', newRelativePathQuery);
}, 300);

function circle(count) {
  const circle = document.createElement('div');
  circle.classList.add('circle');
  circle.innerHTML = `
                    <div class="circle-content">
                        <span class="circle-text">${count}</span>
                    </div>
                `;
  return circle;
}

class CustomRange extends HTMLElement {
  static observedAttributes = ['value', 'max', 'min', 'step'];

  get value() {
    return this.#input.value;
  }

  set value(v) {
    this.#input.value = v;
    this.#updateLine();
  }

  get step() {
    return parseInt(this.#input.step, 10);
  }

  set step(v) {
    this.#input.step = v > 1 ? v : 1;
    this.#updateLine();
    this.#fillBalls();
  }

  get max() {
    return parseInt(this.#input.max, 10);
  }

  set max(v) {
    this.#input.max = v;
    this.#updateLine();
    this.#fillBalls();
  }

  get min() {
    return parseInt(this.#input.min, 10);
  }

  set min(v) {
    this.#input.min = v;
    this.#updateLine();
    this.#fillBalls();
  }

  constructor() {
    super();
    ['change', 'input'].forEach((event) => {
      this.#input.addEventListener(event, (e) => {
        this.#updateLine();

        this.dispatchEvent(
          new Event(event, {
            bubbles: true
          })
        );
      });
    });

    this.#input.type = 'range';
    this.#line.classList.add('input-line');
    this.#wrapper.classList.add('input-wrapper');

    fetch('./input.css').then(
      (resp) => {
        resp.text().then((text) => {
          this.#style.textContent = text;
        });
      },
      () => null
    );
  }

  #updateLine() {
    const {min, max, value} = this;
    const range = max - min;
    const start = value - min;
    this.#wrapper.style.setProperty('--input-line-value', `${(start / range) * 100}%`);
  }

  attributeChangedCallback(key, _, value) {
    this.#input[key] = value;
    this.#updateLine();
  }

  #style = document.createElement('style');
  #line = document.createElement('span');
  #wrapper = document.createElement('span');
  #input = document.createElement('input');

  connectedCallback() {
    const props = Object.values(this.attributes);
    props.forEach((attr) => {
      this.#input.setAttribute(attr.name, attr.value);
    });
    const shadow = this.attachShadow({mode: 'open'});
    shadow.appendChild(this.#wrapper);
    this.#wrapper.appendChild(this.#input);
    this.#wrapper.appendChild(this.#line);
    this.#wrapper.appendChild(this.#style);
    this.#fillBalls();
    this.#updateLine();
  }

  #fillBalls() {
    this.#line.innerHTML = '';
    for (let i = this.min; i <= this.max; i += this.step) {
      const ball = document.createElement('span');
      ball.classList.add('ball');
      this.#line.appendChild(ball);
    }
  }
}

customElements.define('input-range', CustomRange);
/* https://www.smashingmagazine.com/2021/12/create-custom-range-input-consistent-browsers/ */
:root {
  --track-width: 150px;
  --track-height: 2px;
  --thumb-size: 10px;
  --track-bg-color: #122db2;
  --track-bg-image: linear-gradient(var(--track-bg-color), var(--track-bg-color));
  --thumb-bg-color: #fff;
  --thumb-box-shadow: 0 2px 6px 0 #00000033;
  --input-line-value: 0;
}

/********** Range Input Styles **********/
/*Range Reset*/
input[type='range'] {
  box-sizing: border-box;
  -webkit-appearance: none;
  appearance: none;
  cursor: pointer;
  width: var(--track-width);
  margin: 0;
  background: transparent;
  background-image: var(--track-bg-image);
  background-repeat: no-repeat;
  background-size: var(--input-line-value, 0) 100%;
  border-radius: 1000px;
  z-index: 2;
  height: var(--track-height);
}

/* Removes default focus */
input[type='range']:focus {
  outline: none;
}

/***** Chrome, Safari, Opera and Edge Chromium styles *****/
/* slider track */
input[type='range']::-webkit-slider-runnable-track {
  border-radius: var(--track-height);
  height: var(--track-height);
}

/* slider thumb */
input[type='range']::-webkit-slider-thumb {
  -webkit-appearance: none; /* Override default look */
  appearance: none;
  margin-top: calc(var(--thumb-size) / -2 + var(--track-height) / 2); /* Centers the thumb vertically */

  /*custom styles*/
  background-color: var(--thumb-bg-color);
  height: var(--thumb-size);
  width: var(--thumb-size);

  border-radius: 50%;

  box-shadow: var(--thumb-box-shadow);
  position: relative;
  z-index: 2;
}

/******** Firefox styles ********/
/* slider track */
input[type='range']::-moz-range-track {
  border-radius: var(--track-height);
  height: var(--track-height);
}

/* slider thumb */
input[type='range']::-moz-range-thumb {
  border: none;
  border-radius: 50%;

  /*custom styles*/
  background-color: var(--thumb-bg-color);
  height: var(--thumb-size);
  width: var(--thumb-size);

  box-shadow: var(--thumb-box-shadow);
}

.input-line {
  height: 2px;
  position: absolute;
  width: 100%;
  left: 0;
  top: calc(50% - 1px);

  background-repeat: repeat-x;
  background-size: 20px 2px;
  background-position: right;
  z-index: 1;

  display: flex;
  justify-content: space-between;
}

.ball {
  display: inline-block;
  width: 2px;
  height: 2px;
  background-color: #d0d3d6;
  border-radius: 50%;
}

.input-wrapper {
  position: relative;
  display: flex;
  align-items: center;
}

input[type='radio'] {
  box-sizing: border-box;
  margin: 0;
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  border: 2px solid #d9dbdf;
  background-color: #fff;
}

input[type='radio']:checked {
  border-color: #122db2;
  border-width: 3px;
}