import { Clusterer as ClustererElement, useYMaps } from "@pbe/react-yandex-maps"; import type { ReactElement, FC, ReactNode, } from "react"; import { useCallback, useMemo, useState, useEffect, useContext, } from "react"; import type { Clusterer, IClassConstructor, IEvent, ILayout, IGeoObject, IPointGeometry, } from "yandex-maps"; import { constants } from "../../../constants"; import { mapDefaults, optionsDefaults } from "../config"; import type { TCluster, TClusterOptions, TFactoryCallback, TMapActionArg, TMapContext, TMapInstance, ILayoutBalloonCluster, } from "../config/types"; import { MapContext } from "../context"; import { bindEvents, getNodeFromElementInstance, manageGeoObjectClass, managePaneVariables, } from "../lib"; import { ClustererInstanceLayout } from "../lib/clusterer"; type TMapClustererProps = { cluster: TCluster; onReady?: (instance: Clusterer) => void; children?: ReactNode; }; /** * Внутренний компонент содержимого карты * @returns {ReactElement} */ const MapClusterer: FC = ({ onReady, cluster, children, }): ReactElement => { type TClusterActionType = "clusterClick" | "clusterBalloonOpen" | "clusterBalloonClose"; const { containerRef, getCN, onAction, api, } = useContext(MapContext); const ymaps = useYMaps(api?.load ?? mapDefaults.api!.load); const [ instance, setInstance ] = useState(null); const [ layout, setLayout ] = useState<{ icon: IClassConstructor | ""; balloon: IClassConstructor | ""; }>({ icon: "", balloon: "", }); // balloon activity const handleBalloonActivity = useCallback>((type, Balloon) => { const onBalloonClick = (e: IEvent) => { const originalEvent = e .get("domEvent").originalEvent; const target: EventTarget | null = originalEvent.target; if (target && (target as HTMLElement).hasAttribute("data-js-map-cluster-balloon-close")) { originalEvent.preventDefault(); const balloon = Balloon.getData()?.balloon; if (balloon) { balloon.close(); } } if (target && (target as HTMLElement).hasAttribute("data-js-map-cluster-balloon-tab")) { originalEvent.preventDefault(); const pointId = (target as HTMLElement).getAttribute("data-js-map-cluster-balloon-tab"); const data = Balloon.getData(); const activePoint = data?.properties.points.find((p: { id: string; }) => p.id === pointId); if (activePoint) { Balloon.getData().properties.activePoint = activePoint; Balloon.rebuild(); manageGeoObjectClass(Balloon.getElement() as HTMLElement, constants.stateClasses.visible, true); } } }; const getActionArgs = (type: TClusterActionType): TMapActionArg => { return { el: Balloon.getElement(), ref: instance, stateActions: {}, getData: () => { return { type, cluster, }; }, }; }; const manageVars = (isAdd = true) => { const paneNode = Balloon.getParentElement(); queueMicrotask(() => { managePaneVariables(containerRef, isAdd, paneNode); }); }; switch (type) { case "build": { Balloon.events.add("click", onBalloonClick); const parentLayout: HTMLElement = Balloon.getParentElement(); parentLayout.classList.add(getCN("balloons")); (parentLayout.parentNode as HTMLElement)!.style.zIndex = cluster.options?.panelZIndex ?? "6003"; manageVars(true); break; } case "rebuild": { manageVars(true); break; } case "destroy": { Balloon.events.remove("click", onBalloonClick as (event: object | IEvent) => void); if (typeof onAction === "function") { onAction(getActionArgs("clusterBalloonClose")); } manageVars(false); break; } } }, [ instance, onAction, getCN, containerRef, cluster.options ]); useEffect(() => { if (!instance) { return; } const commonArgs = { ymaps, getCN, props: cluster, instance, }; Promise.all([ ClustererInstanceLayout({ ...commonArgs, type: "icon" }), ClustererInstanceLayout({ ...commonArgs, type: "balloon", callback: handleBalloonActivity }), ]) .then(([ icon, balloon ]) => { return setLayout({ icon, balloon, }); }) .catch((err: Error) => { console.error(err); }); }, [ ymaps, instance, getCN, cluster, handleBalloonActivity ]); const currentOptions = useMemo(() => { const size = 48; const { icon } = layout; return { // Основные настройки кластеризации gridSize: 64, groupByCoordinates: false, minimumClusterSize: 2, // Настройки поведения clusterDisableClickZoom: true, clusterOpenBalloonOnClick: false, // Настройки внешнего вида кластеров clusterIconLayout: icon, clusterIconImageSize: [ size, size ], clusterIconImageOffset: [ size / -2, size / -2 ], clusterIconShape: { type: "Rectangle", coordinates: [ [ 0, 0 ], [ size, size ], ], }, // Настройки балуна hasBalloon: false, hasHint: false, // Отключение стандартного поведения clusterHideIconOnBalloonOpen: false, clusterBalloonAutoPan: false, geoObjectHideIconOnBalloonOpen: false, ...cluster.options, }; }, [ cluster.options, layout ]); const handleClick = async (event: IEvent) => { const obj: IGeoObject & { options: TClusterOptions & { _name: string; }; } & Clusterer = event.get("target"); if (obj.options._name === "cluster") { const el = await getNodeFromElementInstance(obj); const getOnActionFn = (type: TClusterActionType) => { if (typeof onAction === "function") { onAction({ event, ref: instance, el, stateActions: {}, getData: () => { return { type, cluster, }; }, }); } }; const defaultFn = async () => { const coordinates = (obj?.geometry)?.getCoordinates(); const map: TMapInstance = obj.getMap(); if (map && coordinates) { if (instance?.balloon) { // balloon activity if (instance!.balloon.isOpen()) { await instance!.balloon.close(true) .then(() => { return getOnActionFn("clusterBalloonClose"); }); } else { const points = obj?.getGeoObjects().map((obj, index) => { const balloonHeader = obj.properties.get("balloonHeader", "" as unknown as object) as unknown as string; const balloonContent = obj.properties.get("balloonContent", "" as unknown as object) as unknown as string; const id = obj.properties.get("idPoint", "" as unknown as object) as unknown as string; const coords = obj.properties.get("coords", "" as unknown as object) as unknown as string[]; return { index, id, coords, balloonHeader: balloonHeader || `# ${(index + 1).toString()}`, balloonContent: balloonContent || coords.join(", "), }; }); const activePoint = points.find((item) => item.index === 0); const map = instance.getMap(); await map.panTo(coordinates, { checkZoomRange: true, duration: 200, useMapMargin: true, }) .then(() => { return instance!.balloon.open(coordinates, { // set actual data properties: { points, activePoint, }, balloon: instance!.balloon, }) // @ts-expect-error (api error) .then(() => getNodeFromElementInstance(instance.balloon)) .then((node: HTMLElement | null) => manageGeoObjectClass(node, constants.stateClasses.visible, true)) .then(() => { return getOnActionFn("clusterBalloonOpen"); }) .catch((err: Error) => { console.error(err); }); }); } } else { // zoom await map.panTo(coordinates, { checkZoomRange: true, duration: 200, useMapMargin: true, }) .then(() => { const maxZoom = map.options.get("maxZoom", optionsDefaults.maxZoom as unknown as object) as unknown as number; const newZoom: number = map.getZoom() + 3; return map.setZoom(newZoom > maxZoom ? maxZoom : newZoom); }) .catch((err: Error) => { console.error(err); }); } } }; if (typeof onAction === "function") { getOnActionFn("clusterClick"); if (!event.getSourceEvent()?.isDefaultPrevented()) { await defaultFn(); } } else { await defaultFn(); } } }; useEffect(() => { if (instance && ymaps) { const events = [ { event: "click", handler: handleClick, }, ]; // unbind current events bindEvents(instance, events, false); // bind current events bindEvents(instance, events, true); // set balloons if (!instance.balloon && instance.options.get("hasBalloon", false as unknown as object) as unknown as boolean && layout.balloon) { const map = instance.getMap(); // @ts-expect-error (api error) instance.balloon = new ymaps.Balloon(map, { autoPan: false, panelMaxMapArea: 0, layout: layout.balloon, }); // @ts-expect-error (api error) instance.balloon.options.setParent(map.options); } return () => { // unbind on unmount bindEvents(instance, events, false); }; } }, [ instance, layout, ymaps ]); useEffect(() => { if (instance && layout.icon && layout.balloon && typeof onReady === "function") { onReady(instance); } }, [ instance, layout ]); return ( { setInstance(instance); }} options={currentOptions} > {children} ); }; export { MapClusterer, };