import { wait } from "@web3r/flowerkit/fn"; import { getMergedObj } from "@web3r/flowerkit/obj"; import type { GeoObject, IEvent, IGeometry, IOverlay, map, Placemark, } from "yandex-maps"; import { getNextClustersByPointClick } from "./getNextClustersByPointClick"; import { handleBalloonDestroy } from "./handleBalloonDestroy"; import { pointDefaults } from "../../config"; import type { ILayoutBalloon, ILayoutHint, ILayoutIcon, TAPI, TBalloonInstance, TConstructorInstance, TFactoryCallback, TMapContext, TMapInstance, TPlacemarkElements, TPoint, TPointDefaults, IMapRef, TPointMetaObj, } from "../../config/types"; import { getLayoutConstructor, getUserField, isCoordsValid, isValidLayout, managePaneVariables, } from "../index"; /** * Получает кластеры с предустановленными параметрами * @param points{TPoint[]=} * @param options * @returns TPoint[] */ const getNormalizedPoints = (points: TPoint[], options: Partial = pointDefaults) => { return (Array.isArray(points) ? points : []) .filter((point) => { if (!!point && point.id && isCoordsValid(point.coords)) { return true; } else { console.error(`[Map] All points must have "id" (string) and "coords" (array with two numbers) props`, point); return false; } }) .map((point) => { const defaults = { ...pointDefaults, ...options, }; const merged = getMergedObj(defaults, point, { isMergeArrays: false, }); merged.icon.size = point?.icon?.size ?? defaults.icon.size; return merged; }); }; /** * Получает экземпляр вызова templateLayoutFactory для указанного типа элемента * @param ymaps * @param props * @param getCN * @param callback * @param type * @returns {object} */ const PlacemarkInstanceLayout = async function({ ymaps, props, getCN, callback, type, map, }: { ymaps: TAPI; props: TPoint; getCN: TMapContext["getCN"]; callback?: TFactoryCallback; type: TPlacemarkElements; map: TMapInstance; }): Promise> { const meta: TPointMetaObj = (props?.meta ?? {})[type] ?? {}; const metaItems = Object.entries(meta || []); const metaValues = await Promise.all(metaItems.map((([ name, value ]) => { const userFnArg = { point: props, instance: ymaps, getCN }; return getUserField(value, userFnArg).then((computedValue): [ string, string ] => [ name, computedValue ]); }))) .then((values) => { return Object.fromEntries(values); }); return getPlacemarkLayout({ point: props, instance: ymaps, type, getCN, }) .then((html: string) => { return getLayoutConstructor(ymaps, html, callback, metaValues); }); }; /** * Получает разметку для Placemark * @param getCN{Function} * @param point{TPoint} * @param type{String} * @param instance{TAPI} * @returns {string} */ const getPlacemarkLayout = async ({ getCN, type, instance, point, }: { getCN: TMapContext["getCN"]; point: TPoint; type: TPlacemarkElements; instance: TAPI; }): Promise => { const userFnArg = { point, instance, getCN }; return new Promise((resolve, reject) => { void (async () => { switch (type) { case "hint": { const { getLayout = null, extraClasses = {} } = point.render?.hint ?? {}; if (typeof getLayout === "function") { // user layout await getUserField(getLayout, userFnArg).then((layout) => { return resolve(layout); }); } else { // default layout resolve(`
{% if properties.hintHeader %}
{{ properties.hintHeader|raw }}
{% endif %} {% if properties.hintContent %}
{{ properties.hintContent|raw }}
{% endif %}
`); } break; } case "pane": case "balloon": { const { getLayout = null, extraClasses = {} } = point.render?.balloon ?? {}; if (typeof getLayout === "function") { // user layout await getUserField(getLayout, userFnArg).then((layout) => { return resolve(layout); }); } else { // default layout resolve(`
{% if properties.balloonHeader %}
{{ properties.balloonHeader|raw }}
{% endif %} {% if properties.balloonContent %}
{{ properties.balloonContent|raw }}
{% endif %}
`); } break; } case "icon": { const { getLayout = null, extraClasses = {} } = point.render?.icon ?? {}; if (typeof getLayout === "function") { // user layout await getUserField(getLayout, userFnArg).then((layout) => { return resolve(layout); }); } else { // default layout const title = await getUserField(point.meta?.icon?.title ?? null, userFnArg) .then((data) => isValidLayout(data) ? data : "") .catch(() => ""); const alt = title.replace(/<\/?[^>]+(>|$)/g, ""); const [ defaultWidth, defaultHeight ] = pointDefaults.icon.size; const [ width = defaultWidth, height = defaultHeight ] = point?.icon?.size ?? [ defaultWidth, defaultHeight ]; const bc = getCN("placemark"); const cn = getCN("placemark", extraClasses); resolve(`
${alt}
`); } break; } default: throw new Error(`Unsupported type "${type}"`); } })().catch(reject); }) .then((layout) => { return isValidLayout(layout) ? layout : Promise.reject(point); }) .catch((err) => { console.error("[Maps] Something went wrong while parsing layout: ", err); return ""; }); }; /** * Получает элемент из событий Placemark * @param event{IEvent} * @returns {HTMLElement|null} */ const getNodeFromPlacemarkEvent: ((event: IEvent) => HTMLElement | null) = (event) => { if (event) { const source = event.getSourceEvent(); const target = source?.originalEvent?.target as map.Container; return target?.getElement() ?? null; } else { return null; } }; /** * Получает вычисленное свойство из блока `meta` точки через обращение к экземпляру вызова фабрики классов * @returns {*} */ const getPropFromInstanceLayout: ((instance: TConstructorInstance | TConstructorInstance | TConstructorInstance | string, prop?: string) => unknown) = (instance, prop) => { return (typeof instance === "string") ? instance : instance.getProps(prop); }; /** * Выполняет стандартное поведение, если у события не вызван метод `preventDefault` * @param event * @param action * @param defaultFn */ const handlePreventableEvent = ({ event, action, defaultFn }: { event: IEvent; action?: () => void; defaultFn: () => void; }) => { if (typeof action === "function") { action(); if (!event.getSourceEvent()?.isDefaultPrevented()) { defaultFn(); } } else { defaultFn(); } }; /** * Получает данные точки без состояния * @param point{TPoint} * @returns {Object} */ const getPointDataForCallback = (point: TPoint): Omit => { const pointData = { ...point }; [ "state", "render", "options", ] .forEach((prop) => { delete pointData[prop as keyof TPoint]; }); return pointData; }; /** * Получает флаг видимости точки в текущей зоне просмотра * @returns {Boolean} */ const isPlacemarkVisible = (placemark: Placemark): boolean => { if (!placemark.getMap()) { return false; } const map = placemark.getMap(); const bounds = map.getBounds(); const coordinates = placemark?.geometry?.getCoordinates(); if (Array.isArray(coordinates)) { const [ lat, lon ] = coordinates; const [ [ south, west ], [ north, east ] ] = bounds; return lat >= south && lat <= north && lon >= west && lon <= east; } else { return false; } }; /** * Открывает или закрывает балун, если он существует у точки * @returns {Promise} */ const getWithBalloon = async (instance: Placemark, isOpen = true, containerRef?: TMapContext["containerRef"]): Promise> | void> => { const manageVars = async (isAdd = true) => { const overlay = await instance.balloon.getOverlay(); const paneNode = (overlay as IOverlay & { getElement: () => HTMLElement | null; })?.getElement(); managePaneVariables(containerRef, isAdd, paneNode); }; if (!!instance.balloon) { if (isOpen) { if (instance.balloon.isOpen()) { // already opened return Promise.resolve(); } else { // try to open return instance.balloon.open() .then(async () => { await manageVars(true); return Promise.resolve(); }); } } else { if (instance.balloon.isOpen()) { // opened return instance.balloon.close() .then(async () => { await manageVars(false); return Promise.resolve(); }); } else { // already closed return Promise.resolve(); } } } else { // not exist return Promise.resolve(); } }; /** * Открывает или закрывает балун, если точка видна во viewport * @returns Promise> */ const getInView = async (instance: Placemark, mapRef: IMapRef["ref"], point: TPoint, containerRef?: TMapContext["containerRef"]): Promise> => { if (!mapRef) { return Promise.resolve(); } const map = mapRef.current; if (!map) { return Promise.resolve(); } if (isPlacemarkVisible(instance)) { // placemark in user viewport return await getWithBalloon(instance, true, containerRef); } const coords = instance?.geometry?.getCoordinates() || point.coords; // Check when `instance.getMap()` is ready const isMapReady = async () => { if (instance.getMap()) { return Promise.resolve(); } else { return Promise.race([ // bind `overlaychange` event on Placemark new Promise((resolve, reject) => { async function onOverlayChange() { if (instance.getMap()) { await wait(500); resolve(); } else { reject(); } instance.events.remove("overlaychange", onOverlayChange); } instance.events.add("overlaychange", onOverlayChange); }), // wait 3 s. and check again new Promise((resolve, reject) => wait(3000) .finally(() => { if (instance.getMap()) { resolve(); } else { console.error(`[Map] Failed to wait map on balloon`, instance); reject(); } }) ), ]); } }; // Set new center and zoom return map.panTo(coords, { checkZoomRange: true, useMapMargin: true, duration: 2000, flying: true, safe: true, }).then(async () => { // Wait for map await isMapReady(); if (isPlacemarkVisible(instance)) { return await getWithBalloon(instance, true, containerRef); } // @ts-expect-error (api) const maxZoom: number = map.options.get("maxZoom") as unknown as number; return map.setZoom(maxZoom, { checkZoomRange: false, useMapMargin: true, duration: 0, }) .then(async () => { // When point is removed/recreated, previous placemark has no map. if (!instance?.getMap?.() || !isPlacemarkVisible(instance)) { return Promise.resolve(); } return await getWithBalloon(instance, true, containerRef); }) .catch((err: Error) => { console.error(err); return Promise.reject(err); }); }); }; export { getInView, getNextClustersByPointClick, getNodeFromPlacemarkEvent, getNormalizedPoints, getPlacemarkLayout, getPointDataForCallback, getPropFromInstanceLayout, getWithBalloon, handleBalloonDestroy, handlePreventableEvent, isPlacemarkVisible, PlacemarkInstanceLayout, };