import { onWindowResize } from "@web3r/flowerkit/evt"; import type { ReactElement, FC } from "react"; import { useRef, lazy, Suspense, useImperativeHandle, useState, useCallback, useMemo, useEffect, } from "react"; import type { IMapState, IMapOptions, Clusterer, IMapBoundsOptions, IEvent, Placemark, } from "yandex-maps"; import { mapDefaults } from "./config"; import type { IMap, IMapRef, TAPI, TCluster, TLangs, TMapContext, TMapInstance, TPoint, } from "./config/types"; import { MapContext } from "./context"; import { getUpdateBounds, getDefaultState, setComputedWidth, isClustersUpdated, } from "./lib"; import { getInitialClusters, getNormalizedClusters } from "./lib/clusterer"; import type { IComponentBaseProps } from "../../types/components"; import { useCN } from "../../utils/react/useCN"; import { useIO } from "../../utils/react/useIO"; import { useLocalisationLanguages } from "../../utils/react/useLocalisationLanguages"; import { useSSGRender } from "../../utils/react/useSSGRender"; import "./style.pcss"; const YandexMap = lazy(() => import(/* webpackChunkName: "yaMap" */"./ui/Map")); type TNumberArrayLike = number[] | number[][] | null | undefined; const getFlatNumberArray = (value: TNumberArrayLike): number[] | null => { if (Array.isArray(value) && value.every((item) => typeof item === "number" && Number.isFinite(item))) { return value as number[]; } return null; }; const isEqualNumberArray = (first: TNumberArrayLike, second: TNumberArrayLike): boolean => { const normalizedFirst = getFlatNumberArray(first); const normalizedSecond = getFlatNumberArray(second); if (!normalizedFirst || !normalizedSecond || normalizedFirst.length !== normalizedSecond.length) { return false; } return normalizedFirst.every((value, index) => value === normalizedSecond[index]); }; /** * Компонент карты. Служит оберткой над Yandex.Maps. * @returns {ReactElement} */ const Map: FC = (props): ReactElement => { const { baseClass = mapDefaults.baseClass, extraClasses = mapDefaults.extraClasses, extraAttrs = mapDefaults.extraAttrs, children = mapDefaults.children, onAction = mapDefaults.onAction, id = mapDefaults.id, isAsync = mapDefaults.isAsync, points = mapDefaults.points, clusters = mapDefaults.clusters, modules = mapDefaults.modules, center = mapDefaults.center, zoom = mapDefaults.zoom, placeholderSlot = mapDefaults.placeholderSlot, onError = mapDefaults.onError, onReady = mapDefaults.onReady, options = mapDefaults.options, margin = mapDefaults.margin, isDisabled = mapDefaults.isDisabled, api = mapDefaults.api, panelSlot = mapDefaults.panelSlot, maxActivePoints = mapDefaults.maxActivePoints, onMount = mapDefaults.onMount, onUnmount = mapDefaults.onUnmount, pointOptions = mapDefaults.pointOptions, clusterOptions = mapDefaults.clusterOptions, isAllPointsVisible = mapDefaults.isAllPointsVisible, ref, } = props || {}; // User clusters const userClusters = useMemo(() => { return getInitialClusters(clusters, points, clusterOptions, pointOptions); }, [ clusters, points, clusterOptions, pointOptions ]); // Classnames const { getCN } = useCN(baseClass); // Container ref const containerRef = useRef(null); // Instance ref const instanceRef = useRef(null); // Current balloon ref const balloonRef = useRef(null); // Callback refs to avoid re-creating handlers const onReadyRef = useRef(onReady); const onErrorRef = useRef(onError); // Update refs when props change useEffect(() => { onReadyRef.current = onReady; onErrorRef.current = onError; }, [ onReady, onError ]); // IO const { observer } = useIO({ callback: (entry: IntersectionObserverEntry[]) => { if (entry) { entry.forEach(({ isIntersecting }) => { if (isIntersecting) { setIsVisible(true); setIsLoading(true); } }); } }, }); // Panel state const [ panelData, setPanelData ] = useState(null); // Visibility state const [ isVisible, setIsVisible ] = useState(!isAsync); // Disabled state const [ isMapDisabled, setIsMapDisabled ] = useState(!!isDisabled); // Loading state const [ isLoading, setIsLoading ] = useState(false); // Updating state const [ isUpdating, setIsUpdating ] = useState(false); // Error state const [ isError, setIsError ] = useState(false); // Current lang const { lang } = useLocalisationLanguages({ isNormalize: true }); // Current query to API const currentQuery = useMemo(() => { return { apikey: api?.key ?? "", lang: lang as TLangs, }; }, [ api, lang ]); // Current map inner state const [ currentState, setCurrentState ] = useState(() => getDefaultState({ zoom, center, margin })); const prevStateRef = useRef(currentState); // Current map inner options const currentOptions = useMemo(() => { return { ...mapDefaults.options, ...options, }; }, [ options ]); // Current map clusters const [ currentClusters, setCurrentClusters ] = useState(userClusters); // Refs on prev clusters state const prevClusters = useRef(currentClusters); const prevActivePoints = useRef(currentClusters.flatMap((c) => c.points.filter((p) => p.state?.isActive)).map((p) => p.id)); // Log when clusters change and close previous balloon useEffect(() => { const activePoints = currentClusters.flatMap((c) => c.points.filter((p) => p.state?.isActive)).map((p) => p.id); // Если активная метка изменилась, закроем старый балун асинхронно const prevActivePointIds = prevActivePoints.current; if (JSON.stringify(activePoints) !== JSON.stringify(prevActivePointIds) && balloonRef.current) { (async () => { try { if (balloonRef.current?.isOpen?.()) { await balloonRef.current.close(); } // Очищаем ссылку после закрытия balloonRef.current = null; } catch (err) { console.warn("[Map] Failed to close balloon:", err); balloonRef.current = null; } })(); } prevActivePoints.current = activePoints; }, [ currentClusters ]); // State utils const stateActions = useMemo(() => ({ /** * Управляет активностью боковой панели. * Используйте `null` для закрытия панели. */ setPanelData, /** * Обновляет массив кластеров. * Для коррекции границ карты требуется обновить критические параметры: уникальные id либо количество точек. */ setClusters: (nextClusters: TCluster[] | ((prevClusters: TCluster[]) => TCluster[]), isShouldNormalize: boolean = true) => { setCurrentClusters((prevClusters) => { const computed = typeof nextClusters === "function" ? nextClusters(prevClusters) : nextClusters; return isShouldNormalize ? getNormalizedClusters(computed, clusterOptions, pointOptions) : computed; }); }, /** * Обновляет флаг активности всей карты */ setIsDisabled: setIsMapDisabled, /** * Обновляет состояние указанной точки. */ setPointState: (idPoint: TPoint["id"], state: Partial) => { if (typeof idPoint === "string" && idPoint.length) { setCurrentClusters((prevClustersState) => { let isFound = false; const nextClustersState = prevClustersState.map((cluster) => { const index = cluster.points.findIndex(({ id }) => id === idPoint); if (index === -1) { return cluster; } isFound = true; return { ...cluster, points: cluster.points.map((point, pointIndex) => { if (pointIndex !== index) { return point; } return { ...point, state: { ...point.state, ...(state || {}), }, }; }), }; }); if (!isFound) { console.error(`[Map] Can't find point with id "${idPoint}"`); } return nextClustersState; }); } else { console.error("[Map] Argument must be non-empty string"); } }, }), [ clusterOptions, pointOptions ]); // Update handle const handleUpdate = useCallback(() => { const mapInstance = instanceRef.current; if (!mapInstance) { return; } const center = mapInstance.getCenter({ useMapMargin: true, }); const zoom = mapInstance.getZoom(); const margin = mapInstance.margin.getMargin(); setCurrentState((prevState) => { const nextCenter = getFlatNumberArray(center) ?? prevState.center; const nextMargin = getFlatNumberArray(margin) ?? prevState.margin; const isEqual = zoom === prevState.zoom && isEqualNumberArray(nextCenter, prevState.center) && isEqualNumberArray(nextMargin, prevState.margin); if (isEqual) { return prevState; } const newState = { ...prevState, center: nextCenter, zoom, margin: nextMargin, }; console.debug(`[Map] Updating state (zoom from "${prevState.zoom}" to "${newState.zoom}", center from "${prevState.center}" to "${newState.center}", margin from "${prevState.margin}" to "${newState.margin}")`); prevStateRef.current = prevState; return newState; }); }, []); // Error handle const handleError = useCallback((error: Error | string) => { setIsLoading(false); setIsError(true); setIsUpdating(false); console.error("[Map] Something is wrong", error); if (typeof onErrorRef.current === "function") { onErrorRef.current({ instance: instanceRef.current, containerRef, error, }); } }, []); // Ready handle const handleReady = useCallback(async (instance: TAPI, clusters: Clusterer[]) => { setIsLoading(false); setIsError(false); setIsUpdating(true); const MapInstance = instanceRef.current; if (MapInstance) { MapInstance.options.set("customProps", { isAllPointsVisible }); await getUpdateBounds(MapInstance) .catch((err: Error | string) => { console.warn(`[Maps] ${(err instanceof Error ? err.message : err.toString())}`); }) .finally(() => { setIsUpdating(false); handleUpdate(); if (typeof onReadyRef.current === "function") { onReadyRef.current({ clusters, instance: MapInstance, containerRef, ymaps: instance, }); } }); } else { handleError(new Error("Can't find map instance")); } }, [ handleError, handleUpdate, isAllPointsVisible ]); // Change ref useImperativeHandle(ref, (): IMapRef => { return { el: containerRef.current, ref: instanceRef, utils: { /** * Обновляет карту для видимости всех точек * @param options{IMapBoundsOptions} * @returns {Promise} */ getUpdateBounds: (options: IMapBoundsOptions): Promise => getUpdateBounds(instanceRef.current, options).then(() => { handleUpdate(); return instanceRef.current; }), }, getData: () => ({ clusters: currentClusters, prevClusters: prevClusters.current, }), stateActions, }; }, [ currentClusters, handleUpdate, stateActions ]); // Set IO useEffect(() => { if (observer && containerRef.current && isAsync) { observer.observe(containerRef.current); return () => observer.disconnect(); } }, [ isAsync, observer ]); useEffect(() => { if (observer && isLoading && isAsync) { observer.disconnect(); } }, [ isAsync, isLoading, observer ]); useEffect(() => { setComputedWidth(containerRef.current); const { removeListener } = onWindowResize(async () => { const mapInstance = instanceRef.current; if (!mapInstance) { return; } setIsUpdating(true); setComputedWidth(containerRef.current); try { await getUpdateBounds(mapInstance); handleUpdate(); } catch (err) { console.error(err); } finally { setIsUpdating(false); } }); if (typeof onMount === "function") { onMount(); } return () => { removeListener(); if (typeof onUnmount === "function") { onUnmount(); } }; }, [ handleUpdate, onMount, onUnmount ]); // update clusters & points from props useEffect(() => { if (userClusters !== prevClusters.current) { setIsUpdating(true); setCurrentClusters((prev) => { console.debug("[Map] Update clusters from:", prev, "to:", userClusters); return userClusters; }); } }, [ userClusters ]); // set bounds after clusters update from state useEffect(() => { const mapInstance = instanceRef.current; const isChanged = isClustersUpdated(currentClusters, prevClusters.current); if (!isChanged || !mapInstance) { return; } const Objects = mapInstance.geoObjects; let isDisposed = false; let onObjectAdds: ((event: object | IEvent) => void) | null = null; const onFinally = () => { if (isDisposed) { return; } prevClusters.current = currentClusters; setIsUpdating(false); }; const updateBounds = async () => { try { await getUpdateBounds(mapInstance); handleUpdate(); } catch (err) { console.error(err); } finally { onFinally(); } }; setIsUpdating(true); if (currentClusters.some((cluster) => cluster.points.length)) { if (Objects.getLength() === currentClusters.length) { void updateBounds(); } else { onObjectAdds = async (_event: object | IEvent) => { if (Objects.getLength() === currentClusters.length) { if (onObjectAdds) { Objects.events.remove("add", onObjectAdds); } await updateBounds(); } }; Objects.events.add("add", onObjectAdds); } } else { onFinally(); } return () => { isDisposed = true; if (onObjectAdds) { Objects.events.remove("add", onObjectAdds); onObjectAdds = null; } }; }, [ currentClusters, handleUpdate ]); // update state useEffect(() => { const newState = getDefaultState({ center, zoom, margin }); const isChanged = prevStateRef.current.zoom !== newState.zoom || !isEqualNumberArray(prevStateRef.current.center, newState.center) || !isEqualNumberArray(prevStateRef.current.margin, newState.margin); if (isChanged) { setCurrentState((prevState) => { return { ...prevState, ...newState, }; }); prevStateRef.current = newState; } }, [ center, zoom, margin ]); const className = getCN("", { isLoading, isUpdating, isError, isDisabled: isMapDisabled, isVisible, isPanel: !!panelData, ...extraClasses, }); const ctx = useMemo(() => ({ api, getCN, points, instanceRef, containerRef, balloonRef, center, zoom, modules, clusters, options, onAction, stateActions, panelSlot, panelData, maxActivePoints, currentClusters, }), [ api, balloonRef, center, clusters, containerRef, currentClusters, getCN, instanceRef, maxActivePoints, modules, onAction, options, panelData, panelSlot, points, stateActions, zoom, ]); const SSGComponent = useSSGRender("data-js-yandex-map", props); if (SSGComponent) { return SSGComponent; } return (
{isVisible && ( {children} )}
); }; export type { IMap, IMapRef }; export { Map, };