Построение маршрута внутри здания
react.tsx
vue.ts
variables.ts
common.ts
common.css
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;
}