import { getLastFromIterable, isNonEmptyArr } from "@web3r/flowerkit/arr"; import { getCSSValue, removeCSSVar, setCSSVar } from "@web3r/flowerkit/css"; import { getDebouncedFn, wait } from "@web3r/flowerkit/fn"; import { getStrWithCapitalized } from "@web3r/flowerkit/str"; import type { Placemark, Clusterer, map, IOverlay, IMapPanOptions, IProjection, IMapBoundsOptions, IMapState, IEvent, geoObject, } from "yandex-maps"; import type ymaps from "yandex-maps"; import { getElRefHelper } from "../../../utils/react/getElRefHelper"; import { mapDefaults, optionsDefaults, userPropsDefaults } from "../config"; import type { TCoords, TZoom, TConstructorInstance, TObjectUserProp, TFactoryCallback, TMapInstance, TAPI, TClusterOptions, TMapContext, TCluster, } from "../config/types"; interface IFixedOverlay extends IOverlay { getLayout: () => map.Container; } type TMapEventHandler = { bivarianceHack: (event: IEvent) => void | Promise; }["bivarianceHack"]; /** * Проверяет валидность массива координат * @returns {boolean} */ const isCoordsValid = (coords?: number[]): boolean => { const items = coords || []; return isNonEmptyArr(items) && items.length === 2 && items.every((item: number | unknown) => typeof item === "number" && Number.isFinite(item)); }; /** * Проверяет валидность полей * @returns {boolean} */ const isMarginValid = (margin?: number[] | number[][]): boolean => { if (Array.isArray(margin)) { return margin.length === 2 && margin.every((value) => typeof value === "number" && Number.isFinite(value) && value > 0); } else { return false; } }; /** * Проверяет валидность зума * @param zoom{TZoom} * @returns {boolean} */ const isZoomValid = (zoom: TZoom | undefined): boolean => { if (typeof zoom === "number" && Number.isFinite(zoom) && zoom > -1 && zoom < 24) { return true; } else { console.error(`[Map] Zoom must be number in range of 0 to 23`, zoom); return false; } }; /** * Проверяет валидность верстки элементов * @param layout{*} * @returns {boolean} */ const isValidLayout = (layout: unknown) => typeof layout === "string" && !!layout.length; /** * Получает экземляр вызова `ymaps.templateLayoutFactory` * @returns {TConstructorInstance} */ const getLayoutConstructor: (instance: TAPI, html?: string, callback?: TFactoryCallback, props?: Record | null, methods?: { [key: string]: Function; }) => TConstructorInstance = function(instance, html, callback, props, methods) { const Layout = instance?.templateLayoutFactory.createClass(html, { build: function() { Layout.superclass.build.call(this); if (typeof callback === "function") { callback("build", this); } }, rebuild: function() { Layout.superclass.rebuild.call(this); if (typeof callback === "function") { callback("rebuild", this); } }, clear: function() { Layout.superclass.clear.call(this); if (typeof callback === "function") { callback("clear", this); } }, destroy: function() { Layout.superclass.destroy.call(this); if (typeof callback === "function") { callback("destroy", this); } }, ...(methods || {}), }, { getProps: (name: string) => { const propMap = props || {}; return !!name ? propMap[name] : propMap; }, }); return Layout; }; /** * Получает значение пользовательского поля, которое может быть функцией * @returns {Promise} */ const getUserField = async (prop: TObjectUserProp, userFnArg: TArg): Promise => { const result = typeof prop === "function" ? await prop(userFnArg) : prop; if (typeof result !== "string") { throw new TypeError("[Map] User field must resolve to a string"); } return result; }; /** * Добавляет или удаляет обработчик события на экземпляр вызова элемента карты * @param instance * @param events * @param isBind */ const bindEvents = ( instance: Placemark | TMapInstance | Clusterer, events: { event: string; handler: TMapEventHandler; }[], isBind: boolean ) => { if (instance) { events.forEach(({ handler, event }) => { instance.events[isBind ? "add" : "remove"]( event, handler as (event: object | IEvent) => void ); }); } }; /** * Добавляет или удаляет классы у экземпляра вызова GeoObject * @param node{HTMLElement|null} * @param className{String} * @param isAdd{Boolean=} * @param callback{Function=} * @returns void; */ const manageGeoObjectClass: ((node: HTMLElement | null, className?: string, isAdd?: boolean, callback?: Function) => void) = (node: HTMLElement | null, className: string = "", isAdd: boolean | undefined = true, callback: Function | undefined) => { if (node && className) { const wrapper = getLastFromIterable(node.children); if (wrapper) { return wait(50) .then(() => { wrapper.classList[isAdd ? "add" : "remove"](className); if (typeof callback === "function") { callback(); } return Promise.resolve(); }); } } }; /** * Получает DOM-элемент из дочернего элемента экземпляра вызова Placemark * @returns {Promise} */ const getNodeFromElementInstance: ((instance: ymaps.IGeoObject & { options: TClusterOptions & { _name: string; }; } | geoObject.Hint | geoObject.Balloon) => Promise) = async (instance): Promise => { if (instance) { return instance.getOverlay() .then((overlay) => (overlay as IFixedOverlay)?.getLayout()) .then((layout: map.Container) => layout?.getElement()); } else { return Promise.resolve(null); } }; /** * Получает результат сдвига карты на заданное число пикселей * @returns {Promise} */ const getMapMoveWithPx: (map: TMapInstance, coords: TCoords, xPx: number, yPx: number, options?: IMapPanOptions) => Promise = async (map, coords, xPx, yPx, options = { flying: true, useMapMargin: true, }): Promise => { if (map) { // Zoom const zoom = map.getZoom(); // Преобразуем координаты в глобальные пиксели const projection = map.options.get("projection", []) as IProjection; const [ currentXPx, currentYPx ] = projection.toGlobalPixels(coords, zoom); // Преобразуем обратно в координаты const newCoordinates = projection.fromGlobalPixels([ currentXPx + xPx, currentYPx + yPx, ], zoom); await map.panTo(newCoordinates, options); return; } else { return Promise.reject(new Error("Map instance is undefined")); } }; /** * Получает результат обновления границ карты * @param instance{TMapInstance} * @param options{IMapBoundsOptions} * @returns {Promise} */ const getUpdateBounds = (instance: TMapInstance, options?: IMapBoundsOptions): Promise => { return new Promise((resolve, reject) => { if (instance) { const userZoom = instance.getZoom(); const GeoObjects = instance.geoObjects; const zoomOptions = { duration: 0, useMapMargin: true, checkZoomRange: true, }; function isAllGeoObjectsVisible(): boolean { const objectsBounds = instance!.geoObjects.getBounds() as number[][]; const mapBounds = instance!.getBounds() as number[][]; return objectsBounds && ( objectsBounds[0][0] >= mapBounds[0][0] && objectsBounds[1][0] <= mapBounds[1][0] && objectsBounds[0][1] >= mapBounds[0][1] && objectsBounds[1][1] <= mapBounds[1][1] ); } const getCorrectViewport = async (bounds: number[][]) => { return await instance.setBounds(bounds, { preciseZoom: false, checkZoomRange: true, useMapMargin: true, duration: 0, ...(options || {}), }).then(async () => { const state = { minZoom: instance.options.get("minZoom", optionsDefaults.minZoom as unknown as object) as unknown as number, maxZoom: instance.options.get("maxZoom", optionsDefaults.maxZoom as unknown as object) as unknown as number, zoom: instance.getZoom(), margin: instance.margin.getMargin(), center: instance.getCenter({ useMapMargin: true, }), }; if (state.maxZoom && state.zoom > state.maxZoom) { return await instance.setZoom(state.maxZoom, zoomOptions) .then(() => { console.debug("[Map] Set zoom to max: ", state.maxZoom); return Promise.resolve(); }); } else if (state.minZoom && state.zoom < state.minZoom) { return await instance.setZoom(state.minZoom, zoomOptions) .then(() => { console.debug("[Map] Set zoom to min: ", state.minZoom); return Promise.resolve(); }); } else { const userOptions = instance.options.get("customProps", userPropsDefaults); const isPointsVisible = isAllGeoObjectsVisible() && (userOptions as typeof userPropsDefaults).isAllPointsVisible; if (isPointsVisible) { return Promise.resolve(); } else { const geoBounds = instance.geoObjects.getBounds() || instance.getBounds({ useMapMargin: true, }); return instance.setBounds(geoBounds, { useMapMargin: true, checkZoomRange: true, duration: 0, }).then(() => { console.debug(`[Map] Zoom level of "${userZoom}" within current map bounds can't show all points, switched to "${instance.getZoom()}". Auto zoom can be disabled via "isAllPointsVisible" prop`); return Promise.resolve(); }); } } }); }; if (GeoObjects && GeoObjects.getLength()) { const bounds = GeoObjects.getBounds(); if (bounds) { void getCorrectViewport(bounds) .then(() => { return resolve(instance); }) .catch((err) => { return reject(err); }); return; } else { const boundsFound = getDebouncedFn(async () => { const bounds = GeoObjects.getBounds(); if (bounds) { await getCorrectViewport(bounds) .then(() => { return resolve(instance); }) .catch((err) => { return reject(err); }) .finally(() => { GeoObjects.events.remove("add", onAdd); GeoObjects.events.remove("boundschange", onBoundsChange); }); } }, 500); function onAdd() { boundsFound(); } function onBoundsChange() { boundsFound(); } GeoObjects.events.add("boundschange", onBoundsChange); GeoObjects.events.add("add", onAdd); } } else { return reject(new Error(`Geo objects not found`)); } } else { return reject(new Error(`Map instance not provided`)); } }); }; /** * Получает начальное состояние карты * @param props{IMapState} * @returns {IMapState} */ const getDefaultState = (props: IMapState) => { const { zoom, center, margin } = props || {}; return { zoom: isZoomValid(zoom) ? zoom : mapDefaults.zoom, center: isCoordsValid(center) ? center : mapDefaults.center, margin: isMarginValid(margin) ? margin : mapDefaults.margin, }; }; /** * Обновляет CSS-переменную вычисляемого размера карты * @param container{HTMLElement | null} */ const setComputedWidth = (container: HTMLElement | null) => { if (container) { setCSSVar(container, "--yaMapComputedWidth", `${container.clientWidth}px`); setCSSVar(container, "--yaMapComputedHeight", `${container.clientHeight}px`); } }; /** * Обновляет CSS-переменные для слоя балунов. */ const managePaneVariables = (containerRef: TMapContext["containerRef"], isAdd: boolean, paneNode: HTMLElement | null) => { const container = getElRefHelper(containerRef); const props = [ "width", "height", "top", "left", "right", "bottom" ]; if (container) { props.forEach((prop) => { const cssVar = `--yaMapBalloons${getStrWithCapitalized(prop)}Computed`; if (isAdd) { // add vars if (paneNode) { const computedValue = getCSSValue(paneNode, prop); setCSSVar(container, cssVar, computedValue); paneNode.style.removeProperty(prop); } } else { // remove vars removeCSSVar(container, cssVar); } }); } }; /** * Проверяет, обновились ли кластеры (по ID или по составу точек, исключая поле state) * @returns {Boolean} true если кластеры изменились, false если изменился только state точек */ const isClustersUpdated = (current: TCluster[], prev: TCluster[]): boolean => { // Если длины различаются - кластеры обновились if (current.length !== prev.length) { return true; } // Проверяем каждый кластер for (let i = 0; i < current.length; i++) { const currentCluster = current[i]; const prevCluster = prev[i]; // Если ID кластера изменился - обновление произошло if (currentCluster.id !== prevCluster.id) { return true; } // Если количество точек в кластере изменилось - обновление произошло if (currentCluster.points.length !== prevCluster.points.length) { return true; } // Проверяем каждую точку в кластере (исключая поле state) for (let j = 0; j < currentCluster.points.length; j++) { const currentPoint = currentCluster.points[j]; const prevPoint = prevCluster.points[j]; // Сравниваем значимые поля, исключая state if ( currentPoint.id !== prevPoint.id || currentPoint.coords[0] !== prevPoint.coords[0] || currentPoint.coords[1] !== prevPoint.coords[1] ) { return true; } } } // Если ничего не изменилось (только state) - обновления нет return false; }; export { bindEvents, getDefaultState, getLayoutConstructor, getMapMoveWithPx, getNodeFromElementInstance, getUpdateBounds, getUserField, isClustersUpdated, isCoordsValid, isMarginValid, isValidLayout, isZoomValid, manageGeoObjectClass, managePaneVariables, setComputedWidth, };