Построение маршрута внутри здания

import type {YMapIndoorLevel, YMapIndoorPlan} from '@yandex/ymaps3-types/modules/layers-extra';
import {boundsAreEqual, fetchRoute, PreparedRouteData, prepareRawData} from '../common';
import {
    ACTIVE_FEATURE_STYLE,
    END_WAYPOINT,
    INIT_LEVEL_ID,
    LOCATION,
    MALL_BOUNDS,
    PASSIVE_FEATURE_STYLE,
    START_WAYPOINT
} from '../variables';

window.map = null;

main();
async function main() {
    const [ymaps3React] = await Promise.all([ymaps3.import('@yandex/ymaps3-reactify'), ymaps3.ready]);
    const reactify = ymaps3React.reactify.bindTo(React, ReactDOM);
    const {YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer, YMapFeature, YMapControls, YMapControl} =
        reactify.module(ymaps3);
    const {YMapIndoorSchemeLayer} = reactify.module(await ymaps3.import('@yandex/ymaps3-layers-extra'));
    const {YMapDefaultMarker} = reactify.module(await ymaps3.import('@yandex/ymaps3-default-ui-theme'));

    function LevelsControl(props: {
        levels: YMapIndoorLevel[];
        activeLevelId: string;
        onClick: (level: YMapIndoorLevel) => void;
    }) {
        return (
            <YMapControls position="left">
                <YMapControl transparent={true}>
                    <div className="level-control">
                        {props.levels.map((level) => {
                            const className = `${level.id === props.activeLevelId ? 'active' : ''}`;
                            return (
                                <button key={level.id} className={className} onClick={() => props.onClick(level)}>
                                    {level.name}
                                </button>
                            );
                        })}
                    </div>
                </YMapControl>
            </YMapControls>
        );
    }

    function App() {
        const [currentPlans, setCurrentPlans] = React.useState<YMapIndoorPlan[]>([]);
        const [activeLevelId, setActiveLevelId] = React.useState<string>(INIT_LEVEL_ID);
        const [routeData, setRouteData] = React.useState<PreparedRouteData>();

        React.useEffect(() => {
            fetchRoute([START_WAYPOINT, END_WAYPOINT]).then((rawData) => {
                const preparedData = prepareRawData(rawData);
                setRouteData(preparedData);
            });
        }, []);

        const onSelectLevel = React.useCallback((level: YMapIndoorLevel) => setActiveLevelId(level.id), []);

        const indoorPlanChanged = React.useCallback(
            (plans: YMapIndoorPlan[]) => {
                setCurrentPlans(plans.filter(({bounds}) => boundsAreEqual(bounds, MALL_BOUNDS)));
            },
            [currentPlans]
        );

        const activePlans = React.useMemo(
            () => currentPlans.map((plan) => ({id: plan.id, levelId: activeLevelId})),
            [currentPlans, activeLevelId]
        );

        const showLevelControl = React.useMemo(() => currentPlans.length > 0, [currentPlans]);

        const levels = React.useMemo(() => {
            const actualLevels = currentPlans
                .map((plan) => plan.levels)
                .flat()
                .sort((a, b) => Number(b.id) - Number(a.id));
            const uniqLevels = _.uniqBy(actualLevels, 'id');
            return uniqLevels;
        }, [currentPlans]);

        const routeFeatures = React.useMemo(() => {
            if (!routeData) return [];

            const features = routeData.lines.map(({geometry, levelId}, index) => {
                const style = levelId === activeLevelId ? ACTIVE_FEATURE_STYLE : PASSIVE_FEATURE_STYLE;
                return <YMapFeature key={`line-${levelId}-${index}`} geometry={geometry} style={style} />;
            });

            const markers = routeData.connectors
                .filter(({levelId}) => levelId === activeLevelId)
                .map(({coordinates, icon, levelId}, index) => {
                    return (
                        <YMapDefaultMarker
                            key={`connector-${levelId}-${index}`}
                            coordinates={coordinates}
                            iconName={icon}
                        />
                    );
                });
            return [...features, ...markers];
        }, [routeData, activeLevelId]);

        return (
            // Initialize the map and pass initialization parameters
            <YMap location={reactify.useDefault(LOCATION)} showScaleInCopyrights={true} ref={(x) => (map = x)}>
                {/* Add a map scheme layer */}
                <YMapDefaultFeaturesLayer />
                <YMapDefaultSchemeLayer />
                <YMapIndoorSchemeLayer onIndoorPlansChanged={indoorPlanChanged} activePlans={activePlans} />

                {showLevelControl && (
                    <LevelsControl levels={levels} activeLevelId={activeLevelId} onClick={onSelectLevel} />
                )}

                {(START_WAYPOINT.level === undefined || activeLevelId === START_WAYPOINT.level) && (
                    <YMapDefaultMarker
                        key="start"
                        iconName="fallback"
                        coordinates={START_WAYPOINT.coordinates}
                        title="start"
                        zIndex={1000}
                    />
                )}
                {(END_WAYPOINT.level === undefined || activeLevelId === END_WAYPOINT.level) && (
                    <YMapDefaultMarker
                        key="end"
                        iconName="fallback"
                        coordinates={END_WAYPOINT.coordinates}
                        title="finish"
                        zIndex={1000}
                    />
                )}

                {routeFeatures}
            </YMap>
        );
    }

    ReactDOM.render(
        <React.StrictMode>
            <App />
        </React.StrictMode>,
        document.getElementById('app')
    );
}
import type {YMapIndoorLevel, YMapIndoorPlan} from '@yandex/ymaps3-types/modules/layers-extra';
import {boundsAreEqual, fetchRoute, PreparedRouteData, prepareRawData} from '../common';
import {
    ACTIVE_FEATURE_STYLE,
    END_WAYPOINT,
    INIT_LEVEL_ID,
    LOCATION,
    MALL_BOUNDS,
    PASSIVE_FEATURE_STYLE,
    START_WAYPOINT
} from '../variables';

window.map = null;

async function main() {
    // For each object in the JS API, there is a Vue counterpart
    // To use the Vue version of the API, include the module @yandex/ymaps3-vuefy
    const [ymaps3Vue] = await Promise.all([ymaps3.import('@yandex/ymaps3-vuefy'), ymaps3.ready]);
    const vuefy = ymaps3Vue.vuefy.bindTo(Vue);
    const {YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer, YMapFeature, YMapControls, YMapControl} =
        vuefy.module(ymaps3);
    const {YMapIndoorSchemeLayer} = vuefy.module(await ymaps3.import('@yandex/ymaps3-layers-extra'));
    const {YMapDefaultMarker} = vuefy.module(await ymaps3.import('@yandex/ymaps3-default-ui-theme'));

    const LevelsControl = Vue.defineComponent({
        props: {
            levels: {type: Array, required: true},
            activeLevelId: {type: String, required: true},
            onClick: {type: Function, required: true}
        },
        components: {YMapControls, YMapControl},
        template: `
        <YMapControls position="left">
            <YMapControl transparent>
                <div class="level-control">
                    <button
                        v-for="level in levels"
                        :key="level.id"
                        @click="onClick(level)"
                        :class="{active: level.id === activeLevelId}">
                        {{level.name}}
                    </button>
                </div>
            </YMapControl>
        </YMapControls>
        `
    });

    const app = Vue.createApp({
        components: {
            YMap,
            YMapDefaultSchemeLayer,
            YMapDefaultFeaturesLayer,
            YMapIndoorSchemeLayer,
            YMapDefaultMarker,
            YMapFeature,
            LevelsControl
        },
        setup() {
            const refMap = (ref) => (window.map = ref?.entity);

            const currentPlans = Vue.ref<YMapIndoorPlan[]>([]);
            const activeLevelId = Vue.ref<string>(INIT_LEVEL_ID);
            const routeData = Vue.ref<PreparedRouteData>();

            Vue.onMounted(() => {
                fetchRoute([START_WAYPOINT, END_WAYPOINT]).then((rawData) => {
                    const preparedData = prepareRawData(rawData);
                    routeData.value = preparedData;
                });
            });

            const onSelectLevel = (level: YMapIndoorLevel) => {
                activeLevelId.value = level.id;
            };
            const indoorPlanChanged = (plans: YMapIndoorPlan[]) => {
                currentPlans.value = plans.filter(({bounds}) => boundsAreEqual(bounds, MALL_BOUNDS));
            };

            const activePlans = Vue.computed(() =>
                currentPlans.value.map((plan) => ({id: plan.id, levelId: activeLevelId.value}))
            );

            const showLevelControl = Vue.computed(() => currentPlans.value.length > 0);

            const levels = Vue.computed(() => {
                const actualLevels = currentPlans.value
                    .map((plan) => plan.levels)
                    .flat()
                    .sort((a, b) => Number(b.id) - Number(a.id));
                const uniqLevels = _.uniqBy(actualLevels, 'id');
                return uniqLevels;
            });

            return {
                LOCATION,
                START_WAYPOINT,
                END_WAYPOINT,
                ACTIVE_FEATURE_STYLE,
                PASSIVE_FEATURE_STYLE,
                activePlans,
                showLevelControl,
                levels,
                activeLevelId,
                routeData,
                refMap,
                indoorPlanChanged,
                onSelectLevel
            };
        },
        template: `
            <YMap :location="LOCATION" :showScaleInCopyrights="true" :ref="refMap">
                <YMapDefaultFeaturesLayer />
                <YMapDefaultSchemeLayer />
                <YMapIndoorSchemeLayer
                    :onIndoorPlansChanged="indoorPlanChanged"
                    :activePlans="activePlans" />

                <LevelsControl
                    v-if="showLevelControl"
                    :levels="levels"
                    :activeLevelId="activeLevelId"
                    :onClick="onSelectLevel" />

                <YMapDefaultMarker
                    v-if="START_WAYPOINT.level === undefined || activeLevelId === START_WAYPOINT.level"
                    key="start"
                    iconName="fallback"
                    :coordinates="START_WAYPOINT.coordinates"
                    title="start"
                    :zIndex="1000"/>
                <YMapDefaultMarker
                    v-if="END_WAYPOINT.level === undefined || activeLevelId === END_WAYPOINT.level"
                    key="start"
                    iconName="fallback"
                    :coordinates="END_WAYPOINT.coordinates"
                    title="start"
                    :zIndex="1000"/>

                <template v-if="routeData">
                    <YMapFeature
                        v-for="(line, index) in routeData.lines"
                        :key="\`line-\${line.levelId}-\${index}\`"
                        :geometry="line.geometry"
                        :style="line.levelId === activeLevelId ? ACTIVE_FEATURE_STYLE : PASSIVE_FEATURE_STYLE" />

                    <template v-for="(connector, index) in routeData.connectors">
                        <YMapDefaultMarker
                            v-if="connector.levelId === activeLevelId"
                            :key="\`connector-\${connector.levelId}-\${index}\`"
                            :coordinates="connector.coordinates"
                            :iconName="connector.icon" />
                    </template>
                </template>
            </YMap>`
    });
    app.mount('#app');
}
main();
import type {DrawingStyle, LngLatBounds, YMapLocationRequest} from '@yandex/ymaps3-types';
import type {IndoorWaypoint} from './common';

export const LOCATION: YMapLocationRequest = {
    center: [37.621176, 55.754577],
    zoom: 17
};

export const MALL_BOUNDS: LngLatBounds = [
    [37.619409634, 55.75363024499373],
    [37.623559701, 55.75576167799372]
];

export const ACTIVE_FEATURE_STYLE: DrawingStyle = {
    simplificationRate: 0,
    stroke: [
        {color: '#7373E6', width: 4, dash: [3, 7]},
        {color: '#ffffff', width: 12}
    ],
    fill: 'none'
};

export const PASSIVE_FEATURE_STYLE: DrawingStyle = {
    simplificationRate: 0,
    stroke: [
        {color: '#7373E6', width: 4, dash: [3, 7], opacity: 0.5},
        {color: 'white', width: 12, opacity: 0.8}
    ],
    fill: 'none'
};

export const ROUTER_API_KEY = '<YOUR_APIKEY>';
export const ROUTER_API_HOST = 'https://api.routing.yandex.net/v2/route';

export const INIT_LEVEL_ID: string = '3';
export const START_WAYPOINT: IndoorWaypoint = {coordinates: [37.619597, 55.755298], level: '1'};
export const END_WAYPOINT: IndoorWaypoint = {coordinates: [37.622773, 55.754137], level: '3'};
import type {LineStringGeometry, LngLat, LngLatBounds} from '@yandex/ymaps3-types';
import {ROUTER_API_HOST, ROUTER_API_KEY} from './variables';

ymaps3.import.registerCdn('https://cdn.jsdelivr.net/npm/{package}', ['@yandex/ymaps3-default-ui-theme@0.0']);

export type IndoorWaypoint = {
    coordinates: LngLat;
    level?: string;
};

export type RouteData = {
    route: {
        legs: RouteLeg[];
    };
};

type RouteLeg = {
    status: 'OK' | 'FAIL';
    steps: RouteStep[];
};

type RouteStep = {
    levels: {
        level: RouteLevel[];
    };
    constructions: {
        construction: RouteConstruction[];
    };
    polyline: {
        points: LngLat[];
    };
};

type RouteLevel = {
    count: number;
    level_info?: {
        connector: boolean;
        level_id?: string;
        level_name?: string;
    };
};

type RouteConstruction = {
    count: number;
    construction_mask: Partial<Record<ConstructionMask, boolean>>;
};

type ConstructionMask =
    | 'stairsUp'
    | 'stairsDown'
    | 'stairsUnknown'
    | 'underpass'
    | 'overpass'
    | 'crosswalk'
    | 'binding'
    | 'transition'
    | 'indoor'
    | 'travolator'
    | 'travolatorUp'
    | 'travolatorDown'
    | 'escalator'
    | 'escalatorUp'
    | 'escalatorDown'
    | 'elevatorUp'
    | 'elevatorDown'
    | 'hasRamp'
    | 'tunnel';

export async function fetchRoute(waypoints: IndoorWaypoint[]): Promise<RouteData> {
    const queryString = new URLSearchParams({
        apikey: ROUTER_API_KEY,
        mode: 'walking',
        waypoints: prepareWaypoints(waypoints),
        levels: waypoints.map(({level}) => level).join(',')
    }).toString();

    const url = `${ROUTER_API_HOST}?${queryString}`;

    const res = await fetch(url);
    const data = await res.json();
    return data;
}

function prepareWaypoints(waypoints: IndoorWaypoint[]): string {
    return waypoints.map(({coordinates: [lng, lat]}) => `${lat},${lng}`).join('|');
}

export type PreparedRouteData = {
    lines: {levelId: string; geometry: LineStringGeometry}[];
    connectors: {levelId: string; coordinates: LngLat; icon: string}[];
};

export function prepareRawData(rawRouteData: RouteData): PreparedRouteData {
    type PointInfo = {
        coordinates: LngLat;
        level_info?: RouteLevel['level_info'];
        construction_mask?: RouteConstruction['construction_mask'];
    };
    const polylineWithInfo: PointInfo[] = [];

    for (const leg of rawRouteData.route.legs) {
        if (leg.status !== 'OK') continue;
        for (const step of leg.steps) {
            const polylinePoints = step.polyline.points;
            const levels = step.levels.level;
            const constructions = step.constructions.construction;

            let levelIndex = 0;
            let constructionIndex = 0;
            for (let pointIndex = 0; pointIndex < polylinePoints.length; pointIndex++) {
                const coordinates = polylinePoints[pointIndex];
                const currentLevel = levels[levelIndex];
                const currentConstruction = constructions[constructionIndex];

                if (!currentLevel || !currentConstruction) {
                    break;
                }

                polylineWithInfo.push({
                    coordinates: [coordinates[1], coordinates[0]],
                    level_info: currentLevel?.level_info,
                    construction_mask: currentConstruction?.construction_mask
                });

                currentConstruction.count--;
                if (currentConstruction.count <= 0) constructionIndex++;

                currentLevel.count--;
                if (currentLevel.count <= 0) levelIndex++;
            }
        }
    }

    const result: PreparedRouteData = {lines: [], connectors: []};
    let geometryBuffer: LngLat[] = [];

    polylineWithInfo.forEach(({coordinates, construction_mask, level_info}, index, points) => {
        const prevPoint = points[index - 1];
        const nextPoint = points[index + 1];
        const isConnector = level_info.connector;
        const levelIds: string[] = isConnector
            ? [prevPoint.level_info.level_id, nextPoint.level_info.level_id]
            : [level_info.level_id];

        if (!isConnector) {
            geometryBuffer.push(coordinates);
        } else {
            result.lines.push({
                levelId: levelIds[0],
                geometry: {type: 'LineString', coordinates: [...geometryBuffer, coordinates]}
            });
            geometryBuffer = [coordinates];
            levelIds.forEach((levelId) =>
                result.connectors.push({coordinates, levelId, icon: getIconName(construction_mask)})
            );
        }
    });

    const lastPoint = polylineWithInfo[polylineWithInfo.length - 1];
    if (lastPoint.level_info.connector) {
        result.connectors.push({
            coordinates: lastPoint.coordinates,
            levelId: lastPoint.level_info.level_id,
            icon: getIconName(lastPoint.construction_mask)
        });
    } else {
        result.lines.push({
            levelId: lastPoint.level_info.level_id,
            geometry: {type: 'LineString', coordinates: geometryBuffer}
        });
    }

    return result;
}

function getIconName(construction_mask?: RouteConstruction['construction_mask']): string {
    if (!construction_mask) return 'fallback';

    if (
        construction_mask.escalator !== undefined ||
        construction_mask.escalatorUp !== undefined ||
        construction_mask.escalatorDown !== undefined
    ) {
        return 'indoor_infra_escalator';
    }

    if (
        construction_mask.stairsDown !== undefined ||
        construction_mask.stairsUp !== undefined ||
        construction_mask.stairsUnknown !== undefined
    ) {
        return 'indoor_infra_stairs';
    }

    return 'fallback';
}

export function boundsAreEqual(boundsA: LngLatBounds, boundsB: LngLatBounds, tolerance = 0.000001): boolean {
    const [minA, maxA] = boundsA;
    const [minB, maxB] = boundsB;
    return (
        Math.abs(minA[0] - minB[0]) < tolerance &&
        Math.abs(maxA[0] - maxB[0]) < tolerance &&
        Math.abs(minA[1] - minB[1]) < tolerance &&
        Math.abs(maxA[1] - maxB[1]) < tolerance
    );
}
.level-control {
    display: flex;
    overflow: hidden;
    flex-direction: column;

    border-radius: 12px;
    background-color: #fff;
    box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.2);
}

.level-control button {
    width: 40px;
    height: 40px;

    font-size: 14px;
    cursor: pointer;

    color: #4d4d4d;
    border: none;
    background: none;
}

.level-control button:hover {
    color: #000;
}

.level-control button.active {
    color: #fff;
    background-color: #4d4d4d;
}