import { Placemark, useYMaps } from "@pbe/react-yandex-maps"; import { isMediaQuery } from "@web3r/flowerkit/css"; import type { FC, ReactNode, RefObject } from "react"; import { useMemo, useEffect, useState, useCallback, useRef, useContext, } from "react"; import type { IEvent, Placemark as PlacemarkInstance } from "yandex-maps"; import { constants } from "../../../constants"; import { mapDefaults, pointDefaults } from "../config"; import type { TPoint, TPointOptions, TMapContext, TFactoryCallback, TMapActionArg, TConstructorInstance, TCluster, TPanelData, TMapInstance, ILayoutHint, ILayoutBalloon, ILayoutIcon, TBalloonInstance, } from "../config/types"; import { MapContext } from "../context"; import { bindEvents, manageGeoObjectClass, getNodeFromElementInstance, getMapMoveWithPx, } from "../lib"; import { PlacemarkInstanceLayout, getNodeFromPlacemarkEvent, getPropFromInstanceLayout, handlePreventableEvent, getPointDataForCallback, getInView, getWithBalloon, getNextClustersByPointClick, handleBalloonDestroy, } from "../lib/placemark"; type TMapPlacemarkProps = { point: TPoint; cluster: TCluster; activePointsCountRef: RefObject; isPanel: boolean; }; /** * Внутренний компонент содержимого карты * @returns {ReactElement} */ export const MapPlacemark: FC = ({ point, activePointsCountRef, isPanel, }: TMapPlacemarkProps): ReactNode => { const { onAction, stateActions: { setClusters, setPanelData, setPointState }, getCN, api, instanceRef: mapRef, balloonRef, containerRef, maxActivePoints, currentClusters, } = useContext(MapContext); const ymaps = useYMaps(api?.load ?? mapDefaults.api!.load); // Panel data const pointPanelDataRef = useRef(null); // instance state const [ instance, setInstance ] = useState(null); // clicked state const [ isActive, setIsActive ] = useState(point.state?.isActive ?? pointDefaults.state.isActive); // visibility state const [ isDisabled, setIsDisabled ] = useState(point.state?.isDisabled ?? pointDefaults.state.isDisabled); // interactivity state const [ isInteract, setIsInteract ] = useState(point.state?.isHasInteraction ?? pointDefaults.state.isHasInteraction); // prev activity state const prevActive = useRef(isActive); // prev disabled state const prevDisabled = useRef(isDisabled); // stored calculated nextIsActive for onAction callback const nextIsActiveRef = useRef(false); // first sync flag for initial active point handling const isFirstStateSyncRef = useRef(true); // Balloon opening flag const isBalloonOpening = useRef(false); // layout state const [ layout, setLayout ] = useState<{ isReady: boolean; instance: { icon: TConstructorInstance | ""; hint: TConstructorInstance | ""; balloon: TConstructorInstance | ""; }; }>({ isReady: false, instance: { icon: "", hint: "", balloon: "", }, }); // stateActions const stateActions = { setIsActive, setIsDisabled, setIsInteract, setPanelData, }; const openPanelForPoint = useCallback(() => { if (!isPanel) { return; } const panelData = { ...getPointDataForCallback(point), onClose: () => { pointPanelDataRef.current = null; setIsActive(false); }, }; setPanelData((prevData: TPanelData) => { if (typeof prevData?.onClose === "function" && prevData?.id !== point.id) { prevData.onClose(); } return panelData; }); pointPanelDataRef.current = panelData; }, [ isPanel, point, setPanelData ]); useEffect(() => { if (!isPanel || !isActive || pointPanelDataRef.current) { return; } openPanelForPoint(); }, [ isPanel, isActive, openPanelForPoint ]); useEffect(() => { const currentActiveState = point.state?.isActive ?? pointDefaults.state.isActive; if (currentActiveState !== isActive) { setIsActive(currentActiveState); } if (point.state?.isDisabled !== isDisabled) { setIsDisabled(point.state?.isDisabled ?? pointDefaults.state.isDisabled); } if (point.state?.isHasInteraction !== isInteract) { setIsInteract(point.state?.isHasInteraction ?? pointDefaults.state.isHasInteraction); } }, [ point.state?.isActive, point.state?.isDisabled, point.state?.isHasInteraction, point ]); // balloon activity const handleBalloonActivity = useCallback>((type, Balloon) => { function onBalloonClick(e: IEvent) { const originalEvent = e.get("domEvent").originalEvent; const target: EventTarget | null = originalEvent.target; if (target && (target as HTMLElement).hasAttribute("data-js-map-balloon-close")) { originalEvent.preventDefault(); Balloon.events.fire("close"); } } // balloon closing function onBalloonClose() { setIsActive(false); } const getActionArgs = (type: "balloonOpen" | "balloonClose"): TMapActionArg => { return { stateActions, el: Balloon.getElement(), ref: instance, getData: () => { return { type, point, }; }, }; }; switch (type) { case "build": { Balloon.events.add("click", onBalloonClick); Balloon.events.add("close", onBalloonClose); // append current instance balloonRef.current = Balloon; const geoObject = Balloon?.getData()?.geoObject; if (geoObject) { const map: TMapInstance = geoObject.getMap(); if (map) { const balloonNode: HTMLElement = Balloon.getElement(); const balloonLayout = balloonNode.children[0]; balloonLayout.classList.add("isAnimating"); const parentLayout: HTMLElement = Balloon.getParentElement(); parentLayout.classList.add(getCN("balloons")); (parentLayout.parentNode as HTMLElement)!.style.zIndex = point.options?.panelZIndex ?? "6003"; const coords = geoObject?.geometry?.getCoordinates() as [ number, number ]; balloonNode.addEventListener("transitionend", async () => { const callback = () => { balloonLayout.classList.remove("isAnimating"); if (typeof onAction === "function") { onAction(getActionArgs("balloonOpen")); } }; if (isMediaQuery(`(${constants.mediaQueries.mobile})`)) { callback(); } else { // desktop-only centering const x = balloonNode.offsetWidth / 2; const y = (balloonNode.offsetHeight / 2); await getMapMoveWithPx(map, coords, x, y) .then(() => { return callback(); }) .catch((err: Error) => { console.error(err); }); } }, { once: true, }); } } break; } case "destroy": { // remove current instance balloonRef.current = null; // @ts-expect-error (api) Balloon.events.remove("click", onBalloonClick); Balloon.events.remove("close", onBalloonClose); handleBalloonDestroy({ setIsActive, onAction, getActionArgs: () => getActionArgs("balloonClose"), }); break; } } }, [ instance, getCN, balloonRef, point, stateActions, onAction ]); // onClick const handleClick = useCallback((event: IEvent) => { // @ts-expect-error (api) const clickedPointId: string = event.originalEvent?.target?.properties?.get?.("idPoint"); if (clickedPointId !== point.id) { return; } const getNextByClick = (clusters: TCluster[]) => getNextClustersByPointClick({ clusters, pointId: point.id, maxActivePoints, }); nextIsActiveRef.current = getNextByClick(currentClusters).nextIsActive; const defaultFn = () => { if (isInteract) { setClusters((clustersState: TCluster[]) => { const { nextClusters, nextIsActive } = getNextByClick(clustersState); nextIsActiveRef.current = nextIsActive; return nextClusters; }, false); } }; const action = typeof onAction === "function" ? () => { onAction({ event, stateActions, el: getNodeFromPlacemarkEvent(event), ref: instance, getData: () => { return { type: "pointClick", point: getPointDataForCallback(point), state: { isActive: nextIsActiveRef.current, isInteract, isDisabled, }, }; }, }); } : undefined; handlePreventableEvent({ event, action, defaultFn }); }, [ point, maxActivePoints, isInteract, onAction, instance, stateActions, setClusters, isDisabled, currentClusters ]); // onMouseLeave const handleMouseLeave = (event: IEvent) => { const defaultFn = async () => { const Hint = instance?.hint; if (Hint?.isOpen()) { await getNodeFromElementInstance(Hint) .then((node) => { return manageGeoObjectClass(node, constants.stateClasses.visible, false, () => { Hint?.close(); }); }) .catch((err: Error) => { console.error(err); }); } }; const action = typeof onAction === "function" ? () => { onAction({ event, stateActions, el: getNodeFromPlacemarkEvent(event), ref: instance, getData: () => { return { type: "pointMouseleave", point: getPointDataForCallback(point), }; }, }); } : undefined; handlePreventableEvent({ event, action, defaultFn }); }; // onMouseEnter const handleMouseEnter = (event: IEvent) => { const defaultFn = async () => { const Hint = instance?.hint; if (!Hint?.isOpen() && !instance?.balloon?.isOpen()) { await Hint?.open() .then(async () => { const node = await getNodeFromElementInstance(Hint); return manageGeoObjectClass(node, constants.stateClasses.visible, true); }) .catch((err: Error) => { console.error(err); }); } }; const action = typeof onAction === "function" ? () => { onAction({ event, stateActions, el: getNodeFromPlacemarkEvent(event), ref: instance, getData: () => { return { type: "pointMouseenter", point: getPointDataForCallback(point), }; }, }); } : undefined; handlePreventableEvent({ event, action, defaultFn }); }; // placemark options const computedOptions = useMemo(() => { // default sizes const [ defaultWidth, defaultHeight ] = pointDefaults.icon.size; // user sizes const [ currentWidth = defaultWidth, currentHeight = defaultHeight, ] = point.icon?.size ?? pointDefaults.icon.size; // balloon offset const balloonOffset: [ number, number ] = [ currentWidth / 2, currentHeight / 2 ]; // hint offset const hintOffset: [ number, number ] = [ currentWidth / 2, currentHeight / 2 ]; // icon offset const iconOffset: [ number, number ] = [ currentWidth / -2, currentHeight / -2 ]; // layouts const { hint, balloon, icon } = layout.instance; // elements const isBalloon = !!getPropFromInstanceLayout(balloon, "title") || !!getPropFromInstanceLayout(balloon, "description"); const isHint = !!getPropFromInstanceLayout(hint, "title") || !!getPropFromInstanceLayout(hint, "description"); // cursor const cursor = (isInteract || isHint) ? "pointer" : "default"; return { iconOffset, iconShape: { type: "Rectangle", coordinates: [ [ 0, 0 ], [ currentWidth, currentHeight ], ], }, openBalloonOnClick: false, openHintOnHover: false, hideIconOnBalloonOpen: false, balloonAutoPan: true, balloonAutoPanUseMapMargin: true, balloonAutoPanCheckZoomRange: true, balloonShadow: false, cursor, balloonOffset, hintOffset, hintLayout: hint, hintCloseTimeout: 200, hintOpenTimeout: 0, iconLayout: icon, balloonLayout: balloon, balloonCloseTimeout: 200, visible: !isDisabled, hasBalloon: isBalloon, hasHint: isHint, draggable: false, balloonPanelMaxMapArea: 0, ...point.options, }; }, [ isInteract, isDisabled, point.options, layout ]); // placemark props const computedProperties = useMemo(() => { const { hint, balloon } = layout.instance; return { // balloon balloonHeader: getPropFromInstanceLayout(balloon, "title"), balloonContent: getPropFromInstanceLayout(balloon, "description"), // hint hintHeader: getPropFromInstanceLayout(hint, "title"), hintContent: getPropFromInstanceLayout(hint, "description"), // states isDisabled, isActive, isInteract, // meta idPoint: point.id, coords: point.coords, }; }, [ isInteract, isActive, isDisabled, layout ]); useEffect(() => { if (!!instance) { // set layout if (!layout.isReady) { const commonArgs = { ymaps, props: point, getCN, map: mapRef.current, }; Promise.all([ PlacemarkInstanceLayout({ ...commonArgs, type: "balloon", callback: handleBalloonActivity }), PlacemarkInstanceLayout({ ...commonArgs, type: "icon" }), PlacemarkInstanceLayout({ ...commonArgs, type: "hint" }), ]) .then(([ balloon, icon, hint ]) => { return setLayout({ isReady: true, instance: { balloon, icon, hint, }, }); }) .catch((err: Error) => { console.error(err); }); } // set/remove events const events = [ { event: "click", handler: handleClick, }, { event: "mouseenter", handler: handleMouseEnter, }, { event: "mouseleave", handler: handleMouseLeave, }, ]; bindEvents(instance, events, false); bindEvents(instance, events, true); const shouldSyncInitialActiveState = isFirstStateSyncRef.current && isActive; isFirstStateSyncRef.current = false; if (shouldSyncInitialActiveState && isPanel) { openPanelForPoint(); } // set activity or disabled state (only for interactive point) if (isInteract) { const isDisabledChanged = prevDisabled.current !== isDisabled; const isActivityChanged = prevActive.current !== isActive; // set a disabled state if (isDisabledChanged) { // update prev ref of disabled state prevDisabled.current = isDisabled; } // check activity if changed or initially active if (isActivityChanged || shouldSyncInitialActiveState) { // update prev ref of activity prevActive.current = isActive; if (isActive) { // increase active count only when state is changed by user interaction if (isActivityChanged) { activePointsCountRef.current++; } // show panel if exist if (isPanel && isActivityChanged) { openPanelForPoint(); } } else { // decrease active count if (activePointsCountRef.current > 0) { activePointsCountRef.current--; } // clear panel data if (isPanel && pointPanelDataRef.current) { setPanelData(null); } } } // update cluster state if (isActivityChanged || isDisabledChanged || shouldSyncInitialActiveState) { setPointState(point.id, { isActive, isDisabled, }); } } return () => { // remove listeners on unmounting bindEvents(instance, events, false); }; } }, [ instance, isActive, isDisabled, ymaps, handleClick, isPanel, openPanelForPoint ]); useEffect(() => { // open or close balloon if API are ready if (!instance?.balloon) { isBalloonOpening.current = false; return; } let isDisposed = false; const applyBalloonState = async () => { // Update loading ref isBalloonOpening.current = true; if (isActive) { if (instance.balloon.isOpen()) { return; } // When switching active points we should release link to previous balloon // so new active point can be opened in the same cycle. const prevBalloon = balloonRef.current; if (prevBalloon && prevBalloon !== instance.balloon) { try { if (prevBalloon.isOpen?.()) { await prevBalloon.close(); } } catch (err) { console.error("[Map] Failed to close previous balloon:", err); } finally { if (balloonRef.current === prevBalloon) { balloonRef.current = null; } } } if (isDisposed) { return; } balloonRef.current = instance.balloon; await getInView(instance, mapRef, point, containerRef) .then(() => getNodeFromElementInstance(instance.balloon)) .then((node: HTMLElement | null) => manageGeoObjectClass(node, constants.stateClasses.visible, true)) .catch((err: Error) => { console.error(err); if (balloonRef.current === instance.balloon) { balloonRef.current = null; } }); } else { if (instance.balloon.isOpen()) { // check if balloon is open await getNodeFromElementInstance(instance.balloon) .then((node) => { return manageGeoObjectClass(node, constants.stateClasses.visible, false, async () => { return getWithBalloon(instance, false, containerRef); }); }) .catch((err: Error) => { console.error(err); }); } // clear ref if this is current balloon if (balloonRef.current === instance.balloon) { balloonRef.current = null; } } }; if (!isBalloonOpening.current) { applyBalloonState() .finally(() => { isBalloonOpening.current = false; }); } return () => { isDisposed = true; }; }, [ instance?.balloon, isActive, layout.isReady ]); return ( { if (inst && !instance) { setInstance(inst); } }} properties={computedProperties} geometry={point.coords} options={computedOptions} /> ); };