Редактор множества объектов

Пример демонстрирует оптимизацию отрисовки множественной геометрии, с возможностью выборочного редактирования.

Благодаря опции dynamic мы можем подсказывать API что собираемся часто обновлять данные в YMapFeatureDataSource.

В примере генерируется 1000 случайных треугольников. Они добавляются в YMapFeatureDataSource #1 с опцией dynamic: false.

API переводит такую геометрию в асинхронный режим отображения, он оптимизирован для отрисовки большого количества объектов.

Если изменить геометрию одного из треугольников, API асинхронно проводит тесселяцию и обновляет представление в видеокарте.

На деле это будет означать ощутимую для глаза задержку.

Поэтому в примере при клике на треугольник, он удаляется из YMapFeatureDataSource #1 и добавляется в YMapFeatureDataSource #2 с опцией dynamic: true.

API переводит геометрию режим синхронного обновления. Отрисовка такой геометрии при изменении, не происходит с задержкой.

При клике на другой треугольник для него операция повторяется, а первый возвращается в YMapFeatureDataSource #1.

Подробнее про опцию dynamic можно почитать в документации.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
        <script crossorigin src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>
        <!-- To make the map appear, you must add your apikey -->
        <script src="https://api-maps.yandex.ru/v3/?apikey=<YOUR_APIKEY>&lang=en_US" type="text/javascript"></script>

        <script
            data-plugins="transform-modules-umd"
            data-presets="typescript"
            type="text/babel"
            src="../variables.ts"
        ></script>
        <script
            data-plugins="transform-modules-umd"
            data-presets="typescript"
            type="text/babel"
            src="./common.ts"
        ></script>
        <script data-plugins="transform-modules-umd" data-presets="typescript" type="text/babel">
            import {BBOX, LOCATION} from '../variables';
            import {FEATURE_COUNT, generateRandomTriangles} from './common';
            
            window.map = null;
            
            main();
            async function main() {
                // Waiting for all api elements to be loaded
                await ymaps3.ready;
                const {
                    YMap,
                    YMapDefaultSchemeLayer,
                    YMapFeatureDataSource,
                    YMapLayer,
                    YMapFeature,
                    YMapListener,
                    YMapDefaultFeaturesLayer,
                    YMapMarker,
                    YMapCollection
                } = ymaps3;
                // Initialize the map
                map = new YMap(
                    // Pass the link to the HTMLElement of the container
                    document.getElementById('app'),
                    // Pass the map initialization parameters
                    {location: LOCATION, showScaleInCopyrights: true},
                    // Add a map scheme layer
                    [
                        new YMapDefaultSchemeLayer({}),
                        new YMapFeatureDataSource({id: 'features-vector', dynamic: false}),
                        new YMapFeatureDataSource({id: 'features-raster', dynamic: true}),
                        new YMapLayer({type: 'features', source: 'features-vector', zIndex: 1400}),
                        new YMapLayer({type: 'features', source: 'features-raster', zIndex: 1401}),
                        new YMapDefaultFeaturesLayer({})
                    ]
                );
            
                const featuresCollection = new YMapCollection({});
                const fakeFeaturesCollection = new YMapCollection({});
                const pointsCollection = new YMapCollection({});
            
                map.addChild(featuresCollection);
                map.addChild(fakeFeaturesCollection);
                map.addChild(pointsCollection);
            
                const triangles = generateRandomTriangles(BBOX, FEATURE_COUNT);
                for (const triangle of triangles) {
                    const feature = new YMapFeature({
                        ...triangle
                    });
                    featuresCollection.addChild(feature);
                }
            
                let previousSelectedIndex = -1;
                let selectedIndex = -1;
                const listener = new YMapListener({
                    onFastClick: (object) => {
                        if (object.type === 'feature') {
                            const newSelectedIndex = triangles.findIndex(
                                (triangle: {id: string}) => triangle.id === object.entity.id
                            );
            
                            if (newSelectedIndex === -1 || selectedIndex === newSelectedIndex) {
                                return;
                            }
            
                            selectedIndex = newSelectedIndex;
                            updateSelected();
                        }
                    }
                });
            
                map.addChild(listener);
            
                function updateSelected() {
                    if (!featuresCollection.children[selectedIndex]) {
                        return;
                    }
            
                    const featureCopy = new YMapFeature({
                        ...triangles[selectedIndex],
                        id: `selected-${triangles[selectedIndex].id}`,
                        source: 'features-raster'
                    });
                    fakeFeaturesCollection.addChild(featureCopy);
                    featuresCollection.children[selectedIndex].update({
                        source: 'features-raster'
                    });
            
                    requestAnimationFrame(
                        ((previousSelectedIndex) => {
                            fakeFeaturesCollection.removeChild(featureCopy);
                            featuresCollection.children[previousSelectedIndex]?.update({
                                source: 'features-vector'
                            });
                        }).bind(null, previousSelectedIndex)
                    );
            
                    previousSelectedIndex = selectedIndex;
            
                    updateSimpleEditor();
                }
            
                function updateSimpleEditor() {
                    if (!triangles[selectedIndex]) {
                        return;
                    }
            
                    if (!pointsCollection.children.length) {
                        for (let i = 0; i < 3; i++) {
                            const div = document.createElement('div');
                            div.classList.add('point');
                            const marker = new YMapMarker(
                                {
                                    coordinates: triangles[selectedIndex].geometry.coordinates[0][i],
                                    onDragMove: (newCoordinates) => {
                                        triangles[selectedIndex].geometry.coordinates[0][i] = newCoordinates;
                                        featuresCollection.children[selectedIndex]?.update({
                                            geometry: {...triangles[selectedIndex].geometry}
                                        });
                                        updatePointsPositions();
                                        return false;
                                    },
                                    draggable: true
                                },
                                div
                            );
                            pointsCollection.addChild(marker);
                        }
                    }
            
                    updatePointsPositions();
                }
            
                function updatePointsPositions() {
                    const selectedTriangle = triangles[selectedIndex];
                    const coordinates = selectedTriangle.geometry.coordinates[0];
                    pointsCollection.children.forEach((point, index) => {
                        point.update({
                            coordinates: coordinates[index]
                        });
                    });
                }
            }
        </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" />
    </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/react@17/umd/react.production.min.js"></script>
        <script crossorigin src="https://cdn.jsdelivr.net/npm/react-dom@17/umd/react-dom.production.min.js"></script>
        <script crossorigin src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>
        <!-- To make the map appear, you must add your apikey -->
        <script src="https://api-maps.yandex.ru/v3/?apikey=<YOUR_APIKEY>&lang=en_US" type="text/javascript"></script>

        <script
            data-plugins="transform-modules-umd"
            data-presets="typescript"
            type="text/babel"
            src="../variables.ts"
        ></script>
        <script
            data-plugins="transform-modules-umd"
            data-presets="typescript"
            type="text/babel"
            src="./common.ts"
        ></script>
        <script data-plugins="transform-modules-umd" data-presets="react, typescript" type="text/babel">
            import {BBOX, LOCATION} from '../variables';
            import {FEATURE_COUNT, type FeatureProps, generateRandomTriangles} from './common';
            import type {DomEvent, DomEventHandlerObject, LngLat} from '@yandex/ymaps3-types';
            
            window.map = null;
            
            main();
            
            async function main() {
                // For each object in the JS API, there is a React counterpart
                // To use the React version of the API, include the module @yandex/ymaps3-reactify
                const [ymaps3React] = await Promise.all([ymaps3.import('@yandex/ymaps3-reactify'), ymaps3.ready]);
                const reactify = ymaps3React.reactify.bindTo(React, ReactDOM);
                const {
                    YMap,
                    YMapDefaultSchemeLayer,
                    YMapFeatureDataSource,
                    YMapLayer,
                    YMapFeature,
                    YMapListener,
                    YMapDefaultFeaturesLayer,
                    YMapMarker,
                    YMapCollection
                } = reactify.module(ymaps3);
                const {useMemo, useState, useCallback} = React;
            
                function App() {
                    const [location, setLocation] = useState(LOCATION);
                    const [triangles, setTriangles] = useState<FeatureProps[]>(() => {
                        return generateRandomTriangles(BBOX, FEATURE_COUNT);
                    }, []);
                    const [selectedIndex, setSelectedIndex] = useState<number>(-1);
            
                    const onSelectFeature = useCallback(
                        (object: DomEventHandlerObject) => {
                            if (object.type === 'feature') {
                                const newSelectedIndex = triangles.findIndex(
                                    (triangle: {id: string}) => triangle.id === object.entity.id
                                );
            
                                if (newSelectedIndex === -1 || selectedIndex === newSelectedIndex) {
                                    return;
                                }
            
                                const newTriangles = triangles.map((triangle) => ({
                                    ...triangle,
                                    source: object.entity.id === triangle.id ? 'features-raster' : 'features-vector'
                                }));
                                setTriangles(newTriangles);
                                setSelectedIndex(newSelectedIndex);
                            }
                        },
                        [selectedIndex, triangles]
                    );
            
                    return (
                        // Initialize the map and pass initialization parameters
                        <YMap location={reactify.useDefault(location)} showScaleInCopyrights={true} ref={(x) => (map = x)}>
                            <YMapDefaultSchemeLayer />
                            <YMapFeatureDataSource id={'features-vector'} dynamic={false} />
                            <YMapFeatureDataSource id={'features-raster'} dynamic={true} />
                            <YMapLayer type={'features'} source={'features-vector'} zIndex={1400} />
                            <YMapLayer type={'features'} source={'features-raster'} zIndex={1401} />
                            <YMapDefaultFeaturesLayer />
                            {triangles.map((feature: FeatureProps) => (
                                <YMapFeature key={feature.id} {...feature} />
                            ))}
                            {triangles[selectedIndex] && (
                                <SimpleGeometryEditor
                                    selectedIndex={selectedIndex}
                                    triangles={triangles}
                                    setTriangles={setTriangles}
                                />
                            )}
            
                            <YMapListener onFastClick={onSelectFeature} />
                        </YMap>
                    );
                }
            
                function SimpleGeometryEditor({
                    selectedIndex,
                    triangles,
                    setTriangles
                }: {
                    selectedIndex: number;
                    triangles: FeatureProps[];
                    setTriangles: (newTriangles: FeatureProps[]) => void;
                }) {
                    return (
                        <YMapCollection>
                            {triangles[selectedIndex].geometry.coordinates[0].map((_, index) => (
                                <SimplePoint
                                    key={`point_${index}`}
                                    pointIndex={index}
                                    featureIndex={selectedIndex}
                                    triangles={triangles}
                                    setTriangles={setTriangles}
                                />
                            ))}
                        </YMapCollection>
                    );
                }
            
                function SimplePoint({
                    featureIndex,
                    pointIndex,
                    triangles,
                    setTriangles
                }: {
                    featureIndex: number;
                    pointIndex: number;
                    triangles: FeatureProps[];
                    setTriangles: (newTriangles: FeatureProps[]) => void;
                }) {
                    const onDragMove = useCallback((newCoordinates: LngLat) => {
                        setTriangles(
                            triangles.map((triangle, triangleIndex) => {
                                if (triangleIndex === featureIndex) {
                                    return {
                                        ...triangle,
                                        geometry: {
                                            ...triangle.geometry,
                                            coordinates: [
                                                triangle.geometry.coordinates[0].map((p, i) => {
                                                    if (i === pointIndex) {
                                                        return newCoordinates;
                                                    }
                                                    return p;
                                                })
                                            ]
                                        }
                                    } as FeatureProps;
                                }
            
                                return triangle;
                            })
                        );
                        return false;
                    }, [featureIndex, pointIndex, triangles]);
            
                    if (!triangles[featureIndex]) {
                        return null;
                    }
            
                    return (
                        <YMapMarker
                            id={`point_${pointIndex}`}
                            coordinates={triangles[featureIndex].geometry.coordinates[0][pointIndex]}
                            onDragMove={onDragMove}
                            draggable={true}
                        >
                            <div className={'point'} />
                        </YMapMarker>
                    );
                }
            
                ReactDOM.render(
                    <React.StrictMode>
                        <App />
                    </React.StrictMode>,
                    document.getElementById('app')
                );
            }
        </script>

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

        <script src="https://api-maps.yandex.ru/v3/?apikey=<YOUR_APIKEY>&lang=en_US" type="text/javascript"></script>
        <script
            data-plugins="transform-modules-umd"
            data-presets="typescript"
            type="text/babel"
            src="../variables.ts"
        ></script>
        <script
            data-plugins="transform-modules-umd"
            data-presets="typescript"
            type="text/babel"
            src="./common.ts"
        ></script>
        <script data-plugins="transform-modules-umd" data-presets="typescript" type="text/babel">
            import {BBOX, LOCATION} from '../variables';
            import {FEATURE_COUNT, generateRandomTriangles} from './common';
            import type {DomEventHandlerObject, LngLat, YMapFeature as YMapfeatureI} from '@yandex/ymaps3-types';
            
            window.map = null;
            
            async function main() {
                // For each object in the JS API, there is a Vue counterpart
                // To use the Vue version of the API, include the module @yandex/ymaps3-vuefy
                const [ymaps3Vue] = await Promise.all([ymaps3.import('@yandex/ymaps3-vuefy'), ymaps3.ready]);
                const vuefy = ymaps3Vue.vuefy.bindTo(Vue);
                const {
                    YMap,
                    YMapDefaultSchemeLayer,
                    YMapFeatureDataSource,
                    YMapLayer,
                    YMapFeature,
                    YMapListener,
                    YMapDefaultFeaturesLayer,
                    YMapMarker,
                    YMapCollection
                } = vuefy.module(ymaps3);
            
                class SimplePoint extends ymaps3.YMapMarker {
                    private __index: number = 0;
                    constructor({
                        index,
                        coordinates,
                        onDragMoveIndex
                    }: {
                        index: number;
                        coordinates: LngLat;
                        onDragMoveIndex: (pointIndex: number, coordinates: LngLat) => void;
                    }) {
                        const div = document.createElement('div');
                        super(
                            {
                                coordinates,
                                onDragMove: (newCoorinates: LngLat) => {
                                    onDragMoveIndex(index, newCoorinates);
                                    return false;
                                },
                                draggable: true
                            },
                            div
                        );
                        div.classList.add('point');
                    }
                }
            
                const app = Vue.createApp({
                    components: {
                        YMap,
                        YMapDefaultSchemeLayer,
                        YMapFeatureDataSource,
                        YMapLayer,
                        YMapFeature,
                        YMapListener,
                        YMapDefaultFeaturesLayer,
                        YMapMarker,
                        YMapCollection,
                        SimplePoint: vuefy.entity(SimplePoint, {
                            index: Number,
                            coordinates: Object as Vue.PropType<LngLat>,
                            onDragMoveIndex: Function
                        })
                    },
                    setup() {
                        const refMap = (ref) => {
                            window.map = ref?.entity;
                        };
            
                        const triangles = Vue.shallowRef(generateRandomTriangles(BBOX, FEATURE_COUNT));
            
                        const selected = Vue.shallowRef({
                            index: -1,
                            coordinates: [] as LngLat[]
                        });
            
                        const onFastClick = (object: DomEventHandlerObject) => {
                            if (object.type === 'feature' && object.entity.geometry.type === 'Polygon') {
                                const newSelectedIndex = triangles.value.findIndex(
                                    (triangle: {id: string}) => triangle.id === object.entity.id
                                );
            
                                if (newSelectedIndex === -1 || selected.value.index === newSelectedIndex) {
                                    return;
                                }
            
                                selected.value = {
                                    index: newSelectedIndex,
                                    coordinates: [...(object.entity.geometry.coordinates[0] as LngLat[])]
                                };
                            }
                        };
            
                        const onDragMove = (index: number, coordinates: LngLat) => {
                            const coordinatesArray = [
                                ...(triangles.value[selected.value.index].geometry.coordinates[0] as LngLat[])
                            ];
                            coordinatesArray[index] = coordinates;
                            selected.value = {
                                ...selected.value,
                                coordinates: coordinatesArray
                            };
                            triangles.value = [
                                ...triangles.value.slice(0, selected.value.index),
                                {
                                    ...triangles.value[selected.value.index],
                                    geometry: {
                                        ...triangles.value[selected.value.index].geometry,
                                        coordinates: [coordinatesArray]
                                    }
                                },
                                ...triangles.value.slice(selected.value.index + 1)
                            ];
                        };
            
                        return {LOCATION, refMap, triangles, onFastClick, selected, onDragMove};
                    },
                    template: `
                        <YMap :location="LOCATION" :showScaleInCopyrights="true" :ref="refMap">
                            <YMapDefaultSchemeLayer />
                            <YMapFeatureDataSource id='features-vector' :dynamic=false />
                            <YMapFeatureDataSource id='features-raster' :dynamic=true />
                            <YMapLayer type='features' source='features-vector' :zIndex=1400 />
                            <YMapLayer type='features' source='features-raster' :zIndex=1401 />
                            <YMapDefaultFeaturesLayer />
                            <YMapCollection>
                            <YMapFeature
                                v-for="(triangle, index) in triangles"
                                :key="triangle.id"
                                :source="index === selected.index ? 'features-raster' : 'features-vector'"
                                :geometry="triangle.geometry"
                                :style="triangle.style"
                                :id="triangle.id"
                            />
                            </YMapCollection>
                            <YMapListener :onFastClick="onFastClick" />
                            <YMapCollection v-if="selected.index !== -1">
                                <SimplePoint
                                    v-for="(coordinates, index) in selected.coordinates"
                                    :key="index"
                                    :index="index"
                                    :coordinates="coordinates"
                                    :onDragMoveIndex="onDragMove"
                                />
                            </YMapCollection>
                        </YMap>`
                });
                app.mount('#app');
            }
            main();
        </script>

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

export const LOCATION: YMapLocationRequest = {
    center: [37.623082, 55.75254], // starting position [lng, lat]
    zoom: 9 // starting zoom
};

export const BBOX: LngLatBounds = [
    [35.25003512499998, 56.368732574338814],
    [39.99612887499998, 55.12641465498419]
];
import type {DrawingStyle, LngLat, LngLatBounds, PolygonGeometry} from '@yandex/ymaps3-types';
export const FEATURE_COUNT = /autotest/.test(location.href) ? 50 : 1000;

const seed = (s: number) => () => {
    s = Math.sin(s) * 10000;
    return s - Math.floor(s);
};
const rnd = seed(10000); // () => Math.random()

function rndColor() {
    const rgb = [Math.floor(rnd() * 256), Math.floor(rnd() * 256), Math.floor(rnd() * 256)];
    return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
}

export type FeatureProps = {
    id: string;
    geometry: PolygonGeometry;
    style: DrawingStyle;
    source: 'features-vector' | 'features-raster';
};

export function generateRandomTriangles(bbox: LngLatBounds, count: number): FeatureProps[] {
    const triangles: FeatureProps[] = [];

    for (let i = 0; i < count; i++) {
        const baseLng = rnd() * (bbox[1][0] - bbox[0][0]) + bbox[0][0];
        const baseLat = rnd() * (bbox[1][1] - bbox[0][1]) + bbox[0][1];

        const coordinates = [
            [
                [baseLng, baseLat] as LngLat,
                [baseLng + rnd() * 0.8 - 0.4, baseLat + rnd() * 0.8 - 0.4] as LngLat,
                [baseLng + rnd() * 0.8 - 0.4, baseLat + rnd() * 0.8 - 0.4] as LngLat
            ]
        ];

        triangles.push({
            id: `triangle-${i}`,
            geometry: {type: 'Polygon', coordinates} as PolygonGeometry,
            style: {
                zIndex: i,
                fill: rndColor(),
                fillOpacity: 0.5,
                stroke: [{width: 3, opacity: 1, color: rndColor()}]
            },
            source: 'features-vector'
        });
    }

    return triangles;
}
.point {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    background-color: #0066ff;
    border: 1px solid #000;
    position: absolute;
    transform: translate(-50%, -50%);
    cursor: grab;
}