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

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 src="https://api-maps.yandex.ru/v3/?apikey=<YOUR_APIKEY>&lang=en_US" type="text/javascript"></script>
        <script src="./variables.js"></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-default-ui-theme');

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

                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>

        <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="./variables.js"></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-default-ui-theme'));

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

                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">
                            <span className="icon"/>
                            <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>

        <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="./variables.js"></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-default-ui-theme'));
                const {YMapClusterer, clusterByGrid} = vuefy.module(
                    await ymaps3.import('@yandex/ymaps3-clusterer')
                );

                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">
                        <span className="icon"/>
                        <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>

        <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>
const FIXED_POINT = [37.623082, 55.75254];
/* 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;
}
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 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);

ymaps3.ready.then(() => {
    ymaps3.import.registerCdn('https://cdn.jsdelivr.net/npm/{package}', ['@yandex/ymaps3-default-ui-theme@0.0', '@yandex/ymaps3-clusterer@0.0'])
});
#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;
}