update route preview

This commit is contained in:
2025-04-14 01:03:58 +03:00
parent 607012bd47
commit b6449b02c0
178 changed files with 8999 additions and 86 deletions
src
index.tsx
pages
preview
assets
components
i18n
lib
AttractionShortPreview
AttractionWidget
Drawer
MapWidget
RoutInfoWidget
TouchScrollWrapper
TransportIcon
WeatherWidget
fonts
icons
index.ts
lightbox
model-viewer
styles
types
utils

@ -1,14 +1,14 @@
import React from 'react' import React from "react";
import {createRoot} from 'react-dom/client' import { createRoot } from "react-dom/client";
import App from './App' import App from "./App";
import './globals.css' import "./globals.css";
const container = document.getElementById('root') as HTMLElement const container = document.getElementById("root") as HTMLElement;
const root = createRoot(container) const root = createRoot(container);
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </React.StrictMode>
) );

@ -130,20 +130,32 @@ export const RouteCreate = () => {
required: "Это поле является обязательным", required: "Это поле является обязательным",
setValueAs: (value: string) => { setValueAs: (value: string) => {
try { try {
// Парсим строку в массив массивов // Разбиваем строку на строки и парсим каждую строку как пару координат
return JSON.parse(value); const lines = value.trim().split("\n");
return lines.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
if (isNaN(lat) || isNaN(lon)) {
throw new Error("Invalid coordinates");
}
return [lat, lon];
});
} catch { } catch {
return []; return [];
} }
}, },
validate: (value: unknown) => { validate: (value: unknown) => {
if (!Array.isArray(value)) return "Неверный формат"; if (!Array.isArray(value)) return "Неверный формат";
if (value.length === 0)
return "Введите хотя бы одну пару координат";
if ( if (
!value.every( !value.every(
(point: unknown) => Array.isArray(point) && point.length === 2 (point: unknown) => Array.isArray(point) && point.length === 2
) )
) { ) {
return "Каждая точка должна быть массивом из двух координат"; return "Каждая строка должна содержать две координаты";
} }
if ( if (
!value.every((point: unknown[]) => !value.every((point: unknown[]) =>
@ -159,14 +171,17 @@ export const RouteCreate = () => {
}, },
})} })}
error={!!(errors as any)?.path} error={!!(errors as any)?.path}
helperText={(errors as any)?.path?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]' helperText={(errors as any)?.path?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
type="text" type="text"
label={"Координаты маршрута *"} label={"Координаты маршрута *"}
name="path" name="path"
placeholder="[[1.1, 2.2], [2.1, 4.5]]" placeholder="55.7558 37.6173
55.7539 37.6208"
multiline
rows={4}
/> />
<TextField <TextField
@ -186,6 +201,7 @@ export const RouteCreate = () => {
<TextField <TextField
{...register("governor_appeal", { {...register("governor_appeal", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})} })}
error={!!(errors as any)?.governor_appeal} error={!!(errors as any)?.governor_appeal}
helperText={(errors as any)?.governor_appeal?.message} helperText={(errors as any)?.governor_appeal?.message}
@ -200,6 +216,7 @@ export const RouteCreate = () => {
<TextField <TextField
{...register("scale_min", { {...register("scale_min", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})} })}
error={!!(errors as any)?.scale_min} error={!!(errors as any)?.scale_min}
helperText={(errors as any)?.scale_min?.message} helperText={(errors as any)?.scale_min?.message}
@ -214,6 +231,7 @@ export const RouteCreate = () => {
<TextField <TextField
{...register("scale_max", { {...register("scale_max", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})} })}
error={!!(errors as any)?.scale_max} error={!!(errors as any)?.scale_max}
helperText={(errors as any)?.scale_max?.message} helperText={(errors as any)?.scale_max?.message}
@ -228,6 +246,7 @@ export const RouteCreate = () => {
<TextField <TextField
{...register("rotate", { {...register("rotate", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})} })}
error={!!(errors as any)?.rotate} error={!!(errors as any)?.rotate}
helperText={(errors as any)?.rotate?.message} helperText={(errors as any)?.rotate?.message}
@ -242,6 +261,7 @@ export const RouteCreate = () => {
<TextField <TextField
{...register("center_latitude", { {...register("center_latitude", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})} })}
error={!!(errors as any)?.center_latitude} error={!!(errors as any)?.center_latitude}
helperText={(errors as any)?.center_latitude?.message} helperText={(errors as any)?.center_latitude?.message}
@ -256,6 +276,7 @@ export const RouteCreate = () => {
<TextField <TextField
{...register("center_longitude", { {...register("center_longitude", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})} })}
error={!!(errors as any)?.center_longitude} error={!!(errors as any)?.center_longitude}
helperText={(errors as any)?.center_longitude?.message} helperText={(errors as any)?.center_longitude?.message}

@ -1,83 +1,132 @@
import {Autocomplete, Box, TextField, FormControlLabel, Checkbox, Typography} from '@mui/material' import {
import {Edit, useAutocomplete} from '@refinedev/mui' Autocomplete,
import {useForm} from '@refinedev/react-hook-form' Box,
import {Controller} from 'react-hook-form' TextField,
import {useParams} from 'react-router' FormControlLabel,
import {LinkedItems} from '../../components/LinkedItems' Checkbox,
import {StationItem, VehicleItem, stationFields, vehicleFields} from './types' Typography,
} from "@mui/material";
import { Edit, useAutocomplete } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form";
import { Controller } from "react-hook-form";
import { useParams } from "react-router";
import { LinkedItems } from "../../components/LinkedItems";
import {
StationItem,
VehicleItem,
stationFields,
vehicleFields,
} from "./types";
export const RouteEdit = () => { export const RouteEdit = () => {
const { const {
saveButtonProps, saveButtonProps,
register, register,
control, control,
formState: {errors}, formState: { errors },
} = useForm({}) } = useForm({});
const {id: routeId} = useParams<{id: string}>() const { id: routeId } = useParams<{ id: string }>();
const {autocompleteProps: carrierAutocompleteProps} = useAutocomplete({ const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({
resource: 'carrier', resource: "carrier",
onSearch: (value) => [ onSearch: (value) => [
{ {
field: 'short_name', field: "short_name",
operator: 'contains', operator: "contains",
value, value,
}, },
], ],
}) });
return ( return (
<Edit saveButtonProps={saveButtonProps}> <Edit saveButtonProps={saveButtonProps}>
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> <Box
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<Controller <Controller
control={control} control={control}
name="carrier_id" name="carrier_id"
rules={{required: 'Это поле является обязательным'}} rules={{ required: "Это поле является обязательным" }}
defaultValue={null} defaultValue={null}
render={({field}) => ( render={({ field }) => (
<Autocomplete <Autocomplete
{...carrierAutocompleteProps} {...carrierAutocompleteProps}
value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null} value={
carrierAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || '') field.onChange(value?.id || "");
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.short_name : '' return item ? item.short_name : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id return option.id === value?.id;
}} }}
filterOptions={(options, {inputValue}) => { filterOptions={(options, { inputValue }) => {
return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase())) return options.filter((option) =>
option.short_name
.toLowerCase()
.includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => <TextField {...params} label="Выберите перевозчика" margin="normal" variant="outlined" error={!!errors.carrier_id} helperText={(errors as any)?.carrier_id?.message} required />} renderInput={(params) => (
<TextField
{...params}
label="Выберите перевозчика"
margin="normal"
variant="outlined"
error={!!errors.carrier_id}
helperText={(errors as any)?.carrier_id?.message}
required
/>
)}
/> />
)} )}
/> />
<TextField <TextField
{...register('route_number', { {...register("route_number", {
required: 'Это поле является обязательным', required: "Это поле является обязательным",
setValueAs: (value) => String(value), setValueAs: (value) => String(value),
})} })}
error={!!(errors as any)?.route_number} error={!!(errors as any)?.route_number}
helperText={(errors as any)?.route_number?.message} helperText={(errors as any)?.route_number?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="text" type="text"
label={'Номер маршрута'} label={"Номер маршрута"}
name="route_number" name="route_number"
/> />
<Controller <Controller
name="route_direction" // boolean name="route_direction" // boolean
control={control} control={control}
defaultValue={false} defaultValue={false}
render={({field}: {field: any}) => <FormControlLabel label="Прямой маршрут?" control={<Checkbox {...field} checked={field.value} onChange={(e) => field.onChange(e.target.checked)} />} />} render={({ field }: { field: any }) => (
<FormControlLabel
label="Прямой маршрут?"
control={
<Checkbox
{...field}
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
/>
}
/>
)}
/> />
<Typography variant="caption" color="textSecondary" sx={{mt: 0, mb: 1}}> <Typography
variant="caption"
color="textSecondary"
sx={{ mt: 0, mb: 1 }}
>
(Прямой / Обратный) (Прямой / Обратный)
</Typography> </Typography>
@ -86,38 +135,68 @@ export const RouteEdit = () => {
control={control} control={control}
defaultValue={[]} defaultValue={[]}
rules={{ rules={{
required: 'Это поле является обязательным', required: "Это поле является обязательным",
validate: (value: unknown) => { validate: (value: unknown) => {
if (!Array.isArray(value)) return 'Неверный формат' if (!Array.isArray(value)) return "Неверный формат";
if (!value.every((point: unknown) => Array.isArray(point) && point.length === 2)) { if (value.length === 0)
return 'Каждая точка должна быть массивом из двух координат' return "Введите хотя бы одну пару координат";
if (
!value.every(
(point: unknown) => Array.isArray(point) && point.length === 2
)
) {
return "Каждая строка должна содержать две координаты";
} }
if (!value.every((point: unknown[]) => point.every((coord: unknown) => !isNaN(Number(coord)) && typeof coord === 'number'))) { if (
return 'Координаты должны быть числами' !value.every((point: unknown[]) =>
point.every(
(coord: unknown) =>
!isNaN(Number(coord)) && typeof coord === "number"
)
)
) {
return "Координаты должны быть числами";
} }
return true return true;
}, },
}} }}
render={({field, fieldState: {error}}) => ( render={({ field, fieldState: { error } }) => (
<TextField <TextField
{...field} {...field}
value={Array.isArray(field.value) ? JSON.stringify(field.value) : ''} value={
Array.isArray(field.value)
? field.value.map((point) => point.join(" ")).join("\n")
: ""
}
onChange={(e) => { onChange={(e) => {
try { try {
const parsed = JSON.parse(e.target.value) const lines = e.target.value.trim().split("\n");
field.onChange(parsed) const parsed = lines.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
if (isNaN(lat) || isNaN(lon)) {
throw new Error("Invalid coordinates");
}
return [lat, lon];
});
field.onChange(parsed);
} catch { } catch {
field.onChange([]) field.onChange([]);
} }
}} }}
error={!!error} error={!!error}
helperText={error?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]' helperText={error?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="text" type="text"
label={'Координаты маршрута'} label={"Координаты маршрута *"}
placeholder="[[1.1, 2.2], [2.1, 4.5]]" placeholder="55.7558 37.6173
55.7539 37.6208"
multiline
rows={4}
sx={{ sx={{
marginBottom: 2, marginBottom: 2,
}} }}
@ -126,111 +205,130 @@ export const RouteEdit = () => {
/> />
<TextField <TextField
{...register('route_sys_number', { {...register("route_sys_number", {
required: 'Это поле является обязательным', required: "Это поле является обязательным",
})} })}
error={!!(errors as any)?.route_sys_number} error={!!(errors as any)?.route_sys_number}
helperText={(errors as any)?.route_sys_number?.message} helperText={(errors as any)?.route_sys_number?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Системный номер маршрута *'} label={"Системный номер маршрута *"}
name="route_sys_number" name="route_sys_number"
/> />
<TextField <TextField
{...register('governor_appeal', { {...register("governor_appeal", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.governor_appeal} error={!!(errors as any)?.governor_appeal}
helperText={(errors as any)?.governor_appeal?.message} helperText={(errors as any)?.governor_appeal?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Обращение губернатора'} label={"Обращение губернатора"}
name="governor_appeal" name="governor_appeal"
/> />
<TextField <TextField
{...register('scale_min', { {...register("scale_min", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})} })}
error={!!(errors as any)?.scale_min} error={!!(errors as any)?.scale_min}
helperText={(errors as any)?.scale_min?.message} helperText={(errors as any)?.scale_min?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Масштаб (мин)'} label={"Масштаб (мин)"}
name="scale_min" name="scale_min"
/> />
<TextField <TextField
{...register('scale_max', { {...register("scale_max", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})} })}
error={!!(errors as any)?.scale_max} error={!!(errors as any)?.scale_max}
helperText={(errors as any)?.scale_max?.message} helperText={(errors as any)?.scale_max?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Масштаб (макс)'} label={"Масштаб (макс)"}
name="scale_max" name="scale_max"
/> />
<TextField <TextField
{...register('rotate', { {...register("rotate", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})} })}
error={!!(errors as any)?.rotate} error={!!(errors as any)?.rotate}
helperText={(errors as any)?.rotate?.message} helperText={(errors as any)?.rotate?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Поворот'} label={"Поворот"}
name="rotate" name="rotate"
/> />
<TextField <TextField
{...register('center_latitude', { {...register("center_latitude", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})} })}
error={!!(errors as any)?.center_latitude} error={!!(errors as any)?.center_latitude}
helperText={(errors as any)?.center_latitude?.message} helperText={(errors as any)?.center_latitude?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Центр. широта'} label={"Центр. широта"}
name="center_latitude" name="center_latitude"
/> />
<TextField <TextField
{...register('center_longitude', { {...register("center_longitude", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})} })}
error={!!(errors as any)?.center_longitude} error={!!(errors as any)?.center_longitude}
helperText={(errors as any)?.center_longitude?.message} helperText={(errors as any)?.center_longitude?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Центр. долгота'} label={"Центр. долгота"}
name="center_longitude" name="center_longitude"
/> />
</Box> </Box>
{routeId && ( {routeId && (
<> <>
<LinkedItems<StationItem> type="edit" parentId={routeId} parentResource="route" childResource="station" fields={stationFields} title="станции" /> <LinkedItems<StationItem>
type="edit"
parentId={routeId}
parentResource="route"
childResource="station"
fields={stationFields}
title="станции"
/>
<LinkedItems<VehicleItem> type="edit" parentId={routeId} parentResource="route" childResource="vehicle" fields={vehicleFields} title="транспортные средства" /> <LinkedItems<VehicleItem>
type="edit"
parentId={routeId}
parentResource="route"
childResource="vehicle"
fields={vehicleFields}
title="транспортные средства"
/>
</> </>
)} )}
</Edit> </Edit>
) );
} };

File diff suppressed because one or more lines are too long

After

(image error) Size: 18 KiB

Binary file not shown.

After

(image error) Size: 8.3 MiB

@ -0,0 +1,49 @@
import { MapWidget, useMapWidgetContext } from '@mt/components';
import { useGetMapData } from './useGetMapData';
import { EventQueryData, useEventQuery, useLoading } from '@mt/utils';
import { useEffect } from 'react';
// TODO: resolve circular deps
import { Coordinates } from '@mt/common-types';
export const MapWidgetContainer = () => {
const { isLoading } = useLoading();
const { data, refetch } = useGetMapData();
const { currentPosition, setCurrentPosition, onMapDataFetched } = useMapWidgetContext();
const { data: events = [], isSuccess } = useEventQuery('/widgets/route-map/events', [
'REFRESH_DATA',
'UPDATE_CURRENT_POINT_ON_TRACK',
]);
useEffect(() => {
if (!data) {
return;
}
onMapDataFetched(data);
}, [data]);
useEffect(() => {
if (!isSuccess || !events.length) {
return;
}
if (events.some((e) => e['@type'] === 'REFRESH_DATA')) {
refetch();
return;
}
const { currentPointOnTrack } = events.at(-1) as EventQueryData<{
currentPointOnTrack: Coordinates;
}>;
setCurrentPosition(currentPointOnTrack);
}, [events, isSuccess, refetch]);
if (isLoading || !data || !currentPosition) {
return null;
}
return <MapWidget />;
};

@ -0,0 +1 @@
export * from './MapWidgetContainer';

@ -0,0 +1,19 @@
import { Track } from '@mt/common-types';
import { StationOnMap } from '@mt/components';
export function mapStationsFromApi(stations: StationOnMap[], track: Track): StationOnMap[] {
return stations.map<StationOnMap>((station) => {
const { pointOnMap } = station;
const trackIndex = track.findIndex(
(trackPoint) => pointOnMap.lat === trackPoint.lat && pointOnMap.lon === trackPoint.lon
);
return {
...station,
pointOnMap: {
...pointOnMap,
trackIndex,
},
};
});
}

@ -0,0 +1,23 @@
import { UseQueryResult, useQuery } from 'react-query';
import { MapData } from '@mt/components';
import { mapStationsFromApi } from './mapStationsFromApi';
export function useGetMapData(): UseQueryResult<MapData> {
return useQuery<MapData>(
'getMapData',
async () => {
const { stationsOnMap, trackPoints, ...rest } = await fetch(
'https://localhost:8443/widgets/route-map/data'
).then((res) => res.json());
return {
trackPoints,
stationsOnMap: mapStationsFromApi(stationsOnMap, trackPoints),
...rest,
};
},
{
refetchOnWindowFocus: false,
}
);
}

@ -0,0 +1,9 @@
import { RouteInfoWidget } from '@mt/components';
import { useGetRouteInfo } from './useGetRouteInfo';
import { HTMLAttributes } from 'react';
export const RouteInfoWidgetContainer = (props: HTMLAttributes<HTMLDivElement>) => {
const { data } = useGetRouteInfo();
return <RouteInfoWidget routeInfo={data} {...props} />;
};

@ -0,0 +1,26 @@
import { useQuery } from 'react-query';
import { useEffect } from 'react';
import { useEventQuery } from '@mt/utils';
import { RouteInfoData } from '@mt/components';
export const useGetRouteInfo = () => {
const { data, isSuccess } = useEventQuery(
// 'getRouteInfoEvents',
'/widgets/route-info/events',
['REFRESH_DATA']
);
const routeInfoQuery = useQuery<RouteInfoData>(
'getRouteInfo',
async () =>
await fetch('https://localhost:8443/widgets/route-info/data').then((res) => res.json())
);
useEffect(() => {
if (isSuccess && data.length) {
routeInfoQuery.refetch();
}
}, [data, isSuccess]);
return routeInfoQuery;
};

@ -0,0 +1,9 @@
import { WeatherWidget } from '@mt/components';
import { HTMLAttributes } from 'react';
import { useWeatherData } from './useWeatherData';
export function WeatherWidgetContainer(props: HTMLAttributes<HTMLDivElement>) {
const { data } = useWeatherData();
return <WeatherWidget weatherData={data} {...props} />;
}

@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
import { useLongPollingQuery } from '@mt/utils';
import { WEATHER_DEFAULTS, WeatherWidgetData } from '@mt/components';
export const useWeatherData = () => {
const [data, setData] = useState<WeatherWidgetData>(WEATHER_DEFAULTS);
const { isSuccess, data: weatherData } = useLongPollingQuery<WeatherWidgetData, Error>(
'getWeatherData',
async () => {
const response = await fetch('https://localhost:8443/widgets/general-info/data');
return response.json();
},
{
pollingInterval: 3000, // client-side delayed long-polling
maxRetries: Number.MAX_SAFE_INTEGER,
}
);
useEffect(() => {
if (isSuccess && weatherData) {
const { weatherInfo, forecasts } = weatherData;
setData({
weatherInfo,
forecasts,
});
}
}, [isSuccess, weatherData]);
return { data };
};

@ -0,0 +1,160 @@
.dropdown {
position: relative;
}
.dropdown-button {
position: relative;
display: flex;
width: 100%;
padding: 18px 0;
justify-content: center;
color: white;
font-weight: 700;
font-size: 18px;
line-height: 21px;
border: none;
background: linear-gradient(
113.51deg,
rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69%
),
#806c59;
border-radius: 10px 10px 0 0;
margin-top: 33px;
cursor: pointer;
align-items: center;
}
.dropdown-icon {
height: 15px;
width: 21px;
left: 40px;
position: absolute;
}
.dropdown-icon--opened {
transform: rotate(180deg);
}
.dropdown-button:hover {
background: #806c59;
}
.dropdown-content {
overflow-y: auto;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 675px;
padding: 0;
background: linear-gradient(
113.51deg,
rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69%
),
#806c59;
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25);
border-radius: 10px 10px 0 0;
z-index: 100;
}
.dropdown-content-header {
display: flex;
align-items: center;
width: 100%;
padding: 15px 0;
font-weight: 700;
font-size: 18px;
line-height: 21px;
text-align: center;
color: #ffffff;
border-bottom: 1px solid #ccc;
}
.dropdown-content-list {
/*52px - height of the header*/
height: calc(675px - 52px);
padding-right: 35px;
}
.alphabet-list {
position: fixed;
right: 35px;
bottom: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
justify-content: space-between;
/*52px - height of the header*/
/*28px - top&bottom paddings from .attraction-list*/
height: calc(675px - 52px - 28px);
}
.alphabet-list li {
text-align: center;
font-weight: 400;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-grow: 1;
width: 35px;
color: #ffffff;
cursor: pointer;
}
.alphabet-list li:first-child {
padding-top: 0;
}
.attraction-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: 150px;
gap: 24px 0;
padding: 14px 0;
margin: 0;
}
.attraction-list li {
display: flex;
align-items: center;
flex-direction: column;
}
.attraction-list li:hover {
cursor: pointer;
}
.attraction-list li .dropdown-list-item__image-wrapper {
background: #ffffff;
border-radius: 50%;
width: 96px;
height: 96px;
display: flex;
align-items: center;
justify-content: center;
}
.attraction-list li .dropdown-list-item__image-wrapper img {
max-width: 60px;
}
.attraction-list li span {
width: 150px;
max-height: 40px;
text-overflow: ellipsis;
overflow: hidden;
margin-top: 8px;
font-weight: 600;
font-size: 16px;
line-height: 19px;
text-align: center;
letter-spacing: 0.02em;
color: #ffffff;
}

@ -0,0 +1,127 @@
import { useServerLocalization } from "@mt/i18n";
import React, { useCallback, useEffect, useRef, useState } from "react";
import "./AllAttractionsDropdown.css";
import { FormattedMessage } from "react-intl";
import { useGetAttractionList } from "./useGetAttractionList";
import { TouchScrollWrapper } from "@mt/lib";
interface AllAttractionsDropdownProps {
isIdleMode: boolean;
handleAttractionClick: (id: string) => void;
}
const AllAttractionsDropdown = React.memo(
({ isIdleMode, handleAttractionClick }: AllAttractionsDropdownProps) => {
const { data: attractions = [] } = useGetAttractionList();
const [isOpen, setIsOpen] = useState(false);
const attractionsListRef = useRef<HTMLUListElement>(null);
const localizeText = useServerLocalization();
const letters = attractions
.sort((a, b) =>
localizeText(a.name)
.toLowerCase()
.localeCompare(localizeText(b.name).toLowerCase())
)
.reduce((acc: string[], cur) => {
const firstLetter = localizeText(cur.name).charAt(0).toUpperCase();
if (!acc.includes(firstLetter)) {
return acc.concat(firstLetter);
}
return acc;
}, []);
const handleLetterClick = useCallback(
(letter: string) => {
const attractionByFirstLetter = attractions.find((it) =>
localizeText(it.name).startsWith(letter)
);
if (attractionByFirstLetter) {
const item = attractionsListRef.current?.querySelector(
`#attraction-${attractionByFirstLetter.id}`
);
item?.scrollIntoView({
behavior: "smooth",
block: "end",
inline: "nearest",
});
}
},
[attractions, localizeText]
);
useEffect(() => {
if (isIdleMode) setIsOpen(false);
}, [isIdleMode]);
return (
<div className="dropdown">
<button
className="dropdown-button"
onPointerUp={() => setIsOpen(!isOpen)}
>
<Icons.ChevronIcon className="dropdown-icon" />
<div className="g-flex__item">
<FormattedMessage id="attractions" />
</div>
</button>
{isOpen && (
<div className="dropdown-content">
<div
className="dropdown-content-header"
onPointerUp={() => setIsOpen(!isOpen)}
>
<Icons.ChevronIcon className="dropdown-icon dropdown-icon--opened" />
<div className="g-flex__item">
<FormattedMessage id="attractions" />
</div>
</div>
<TouchScrollWrapper className="dropdown-content-list">
<ul className="attraction-list" ref={attractionsListRef}>
{attractions.map((attraction) => (
<li
id={`attraction-${attraction.id}`}
key={attraction.id}
className="dropdown-list-item"
onPointerUp={() => {
setIsOpen(!isOpen);
handleAttractionClick(attraction.id);
}}
>
<div className="dropdown-list-item__image-wrapper">
<img
src={attraction.iconUrl}
alt={localizeText(attraction.name)}
/>
</div>
<span>{localizeText(attraction.name)}</span>
</li>
))}
</ul>
</TouchScrollWrapper>
<ul className="alphabet-list">
{letters.map((letter) => (
<li key={letter} onPointerUp={() => handleLetterClick(letter)}>
<span>{letter}</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}
);
export default AllAttractionsDropdown;

@ -0,0 +1,36 @@
import { useQuery, UseQueryResult } from 'react-query';
import { LocalizedString } from '@mt/i18n';
import { useEventQuery } from '@mt/utils';
import { useEffect } from 'react';
interface Attraction {
id: string;
name: LocalizedString;
iconUrl: string;
}
export function useGetAttractionList(): UseQueryResult<Attraction[]> {
const { data, isSuccess } = useEventQuery('/widgets/attraction-with-details-list/events', [
'REFRESH_DATA',
]);
const attractionListQuery = useQuery<Attraction[]>(
['getAttractionList'],
async () =>
await fetch('https://localhost:8443/widgets/attraction-with-details-list/data')
.then((res) => res.json())
.then(({ touristAttractions }) => touristAttractions),
{
refetchOnWindowFocus: false,
refetchOnMount: false,
}
);
useEffect(() => {
if (isSuccess && data.length) {
attractionListQuery.refetch();
}
}, [data, isSuccess]);
return attractionListQuery;
}

@ -0,0 +1,158 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { AttractionWidget } from '@mt/components';
import AllAttractionsDropdown from '../all-attractions-dropdown/all-attractions-dropdown';
import './AttractionsWidgetContainer.css';
import { useLightboxContext } from '@mt/components';
// TODO: resolve circular deps
import { ArticleBase, PhotoSphereLightboxData } from '@mt/common-types';
import { Article } from '@front/types';
const POLL_INTERVAL_MS = 5000;
const IDLE_TIME_MS = 2 * 60 * 1000; // Idle time in milliseconds before restarting polling (2 minutes)
// const IDLE_TIME_MS = 15 * 1000; // debug
export function AttractionWidgetContainer() {
const [touristArticles, setTouristArticles] = useState<ArticleBase[] | null>(null);
const [error, setError] = useState<TypeError | null>(null);
const [isPolling, setIsPolling] = useState<boolean>(true);
const [isIdleMode, setIdleMode] = useState<boolean>(true);
const idleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const pollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const { closeLightbox } = useLightboxContext<PhotoSphereLightboxData>();
const startPolling = useCallback(async () => {
closeLightbox();
setIsPolling(true);
abortControllerRef.current = new AbortController();
const poll = async (attempt = 1) => {
try {
const response = await fetch('https://localhost:8443/widgets/attraction-details/events', {
signal: abortControllerRef.current?.signal,
}).then((resp) => resp.json());
// if we receive empty array - preserve previous attraction on the screen
if (response?.[0]) {
const [{ touristAttractionInfoPages }] = response;
const articles: ArticleBase[] = touristAttractionInfoPages.map(
({ title: name, ...rest }: Article) => ({ name, ...rest })
);
setTouristArticles(articles);
}
setError(null);
if (isPolling) {
// Schedule the next poll
pollTimeoutRef.current = setTimeout(poll, POLL_INTERVAL_MS);
}
} catch (error: unknown) {
if ((error as TypeError).name === 'AbortError') {
console.log('Request was cancelled');
} else {
setError(error as TypeError);
if (isPolling && attempt < 3) {
// Schedule the next poll
pollTimeoutRef.current = setTimeout(() => poll(attempt + 1), POLL_INTERVAL_MS);
}
}
}
};
await poll();
return () => {
abortControllerRef.current?.abort();
};
}, [isPolling]);
const stopPolling = () => {
setIsPolling(false);
abortControllerRef.current?.abort();
if (pollTimeoutRef.current) {
clearTimeout(pollTimeoutRef.current);
}
};
const handleUserAction = (e: Event) => {
const widgetContainer = containerRef.current;
if (!widgetContainer || !e.target) return;
if (!widgetContainer.contains(e.target as Node)) return;
e.stopPropagation();
resetIdleMode();
};
const handleAttractionSelect = useCallback(async (id: string) => {
const { touristAttractionInfoPages } = await fetch(
'https://localhost:8443/widgets/attraction-details/data-by-params?' +
new URLSearchParams({ touristAttractionId: id })
).then((resp) => resp.json());
resetIdleMode();
const articles: ArticleBase[] = touristAttractionInfoPages.map(
({ title: name, ...rest }: Article) => ({ name, ...rest })
);
setTouristArticles(articles);
// https://tracker.yandex.ru/TS-32
document.querySelector('.widget-text.active')!.scrollTop = 0;
}, []);
const resetIdleMode = () => {
abortControllerRef.current?.abort(); // cancel ongoing requests
idleTimeoutRef.current && clearTimeout(idleTimeoutRef.current); // reset idle timeout
setIdleMode(false);
idleTimeoutRef.current = setTimeout(() => setIdleMode(true), IDLE_TIME_MS);
};
useEffect(() => {
isIdleMode ? startPolling() : stopPolling();
return stopPolling;
}, [isIdleMode, startPolling]);
// Attach event listeners to stop polling on user actions
useEffect(() => {
window.addEventListener('click', handleUserAction);
window.addEventListener('scroll', handleUserAction);
return () => {
window.removeEventListener('click', handleUserAction);
window.removeEventListener('scroll', handleUserAction);
if (idleTimeoutRef.current) {
clearTimeout(idleTimeoutRef.current);
}
};
}, [containerRef]);
return error ? (
<p>Error: {error.message}</p>
) : (
touristArticles && (
<div
className="attractions-widget-container g-flex-column g-flex--justify-end"
ref={containerRef}
>
<AttractionWidget articles={touristArticles} isIdleMode={isIdleMode} />
<AllAttractionsDropdown
isIdleMode={isIdleMode}
handleAttractionClick={handleAttractionSelect}
/>
</div>
)
);
}

@ -0,0 +1,4 @@
.attractions-widget-container {
padding: 32px 32px 0 0;
height: 100%;
}

@ -0,0 +1,70 @@
import styled from "@emotion/styled";
import { AttractionWidgetContainer } from "../attractions-widget/AttractionWidgetContainer";
import { WeatherWidgetContainer } from "../WeatherWidget/WeatherWidgetContainer";
import { OperativeInfoWidget } from "../operative-info-widget/operative-info-widget";
import { NavWidgetContainer } from "../nav-widget/nav-widget-container";
import { MapWidgetContainer } from "../MapWidgetContainer";
import { RouteInfoWidgetContainer } from "../RouteInfoWidgetContainer/RouteInfoWidgetContainer";
const StyledDashboard = styled.div`
background-color: #000;
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
display: flex;
.nav-widget--opened + .container {
margin-left: 290px;
}
.container {
position: relative;
width: 100%;
height: 100%;
margin-left: 0;
transition: margin-left ease-in-out 0.3s;
.left-top-wrapper {
position: absolute;
.route-number {
margin: 32px;
}
.weather-widget {
margin: 32px;
}
}
}
.right-sidebar {
flex-shrink: 0;
}
`;
export function Dashboard() {
return (
<StyledDashboard>
<NavWidgetContainer />
<div className="container">
<div className="left-top-wrapper">
<RouteInfoWidgetContainer className="route-number" />
<WeatherWidgetContainer className="weather-widget" />
</div>
<MapWidgetContainer />
<OperativeInfoWidget />
</div>
<div className="right-sidebar">
<AttractionWidgetContainer />
</div>
</StyledDashboard>
);
}

@ -0,0 +1,35 @@
import { useEffect } from 'react';
import { Lightbox } from '@mt/components';
import { LoadingScreen, useLoading } from '@mt/utils';
import { useBackendStatus } from './useBackendStatus';
import { Dashboard } from '../dashboard/Dashboard';
import { useSplashScreenIsDisplayed } from './useSplashScreenStatus';
export function MainScreen() {
const { isLoading, hideLoadingScreen, showLoadingScreen } = useLoading();
const { backendStatus } = useBackendStatus();
const { isSplashScreenDisplayed } = useSplashScreenIsDisplayed();
useEffect(() => {
if (backendStatus === 'UP' && !isSplashScreenDisplayed) {
hideLoadingScreen();
} else if (backendStatus === 'DOWN' || isSplashScreenDisplayed) {
showLoadingScreen();
}
}, [
backendStatus,
isSplashScreenDisplayed,
hideLoadingScreen,
showLoadingScreen,
]);
return isLoading ? (
<LoadingScreen />
) : (
<>
<Dashboard />
<Lightbox />
</>
);
}

@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from 'react';
import { useLoading } from '@mt/utils';
type BE_STATUS = 'UP' | 'DOWN';
interface BackendHealthResponse {
status: BE_STATUS;
}
export const useBackendStatus = () => {
const [backendStatus, setBackendStatus] = useState<BE_STATUS>('DOWN');
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const { isLoading } = useLoading();
useEffect(() => {
const retry = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => checkStatus(), 1000);
};
const checkStatus = async () => {
await fetch('https://localhost:8443/_app/actuator/health')
.then((response) => {
if (!response.ok && response.status === 404) {
retry();
} else {
return response.json();
}
})
.then((data: BackendHealthResponse) => {
if (data.status === 'UP') {
setBackendStatus('UP');
} else {
setBackendStatus('DOWN');
retry();
}
})
.catch((error) => {
setBackendStatus('DOWN');
retry();
});
};
if (isLoading) {
checkStatus();
}
}, [isLoading]);
return { backendStatus };
};

@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
import { EventQueryData, useEventQuery } from '@mt/utils';
export const useSplashScreenIsDisplayed = () => {
const [isSplashScreenDisplayed, setIsSplashScreenDisplayed] =
useState<boolean>();
const { data: events, isSuccess } = useEventQuery(
'/widgets/splash-screen/events',
['UPDATE_DISPLAYED_FLAG'],
{ maxRetries: Number.MAX_SAFE_INTEGER }
);
useEffect(() => {
if (!isSuccess || !events.length) {
return;
}
const { isDisplayed } = events.at(-1) as EventQueryData<{
isDisplayed: boolean;
}>;
setIsSplashScreenDisplayed(isDisplayed);
}, [isSuccess, events]);
return { isSplashScreenDisplayed };
};

@ -0,0 +1,108 @@
import cn from 'classnames';
import { HTMLAttributes, useCallback, useId, useMemo, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { TouchScrollWrapper, TransportIcon } from '@mt/components';
import { uuid } from '@mt/common-types';
import { useServerLocalization } from '@mt/i18n';
import { Attraction, Station } from '../../nav-widget.interface';
import { NavAccordion, NavAccordionSummary, NavAccordionDetails } from '../../nav-widget.styles';
import { getDistanceFormatter } from '../get-distance-formatter';
const simulateClick = (id: uuid) => {
document.getElementById(`accordSummary_${id}`)?.click();
};
export type NestedItems = Array<
Attraction & Omit<Station, 'type'> & Partial<Pick<Station, 'type'>>
>;
export interface AccordionItem
extends Omit<Station | Attraction, 'nearbyTouristAttractions' | 'nearbyTransportStops'> {
nestedItems: NestedItems;
}
interface Props extends HTMLAttributes<HTMLDivElement> {
titleId: string;
isOpened: boolean;
items: AccordionItem[];
onExpandChange?: (isExpanded: boolean, itemId: uuid) => void;
onNestedItemClick?: (itemId: uuid) => void;
}
export const AccordionListTab = (props: Props) => {
const { titleId, isOpened, items, onExpandChange, onNestedItemClick, ...args } = props;
const localizeText = useServerLocalization();
const formatDistance = getDistanceFormatter(localizeText);
const [expandedItem, setExpandedItem] = useState<uuid | null>(null);
const scrollToExpanded = useCallback((id: uuid) => {
const expandedEl = document.getElementById(`accordWrapper_${id}`);
expandedEl?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
});
}, []);
const tabClasses = useMemo(
() =>
cn('g-flex-column__item-fixed g-flex-column tab-container', {
'tab-container--opened': isOpened,
}),
[isOpened]
);
const isExpanded = useCallback((id: uuid) => id === expandedItem, [expandedItem]);
const handleChange = (isExpanded: boolean, itemId: uuid) => {
setExpandedItem(isExpanded ? itemId : null);
onExpandChange?.(isExpanded, itemId);
};
return (
<div className={tabClasses} {...args}>
<div className="g-flex-column__item-fixed tab-title">
<FormattedMessage id={titleId} />
</div>
<TouchScrollWrapper className="g-flex-column__item">
{items.map(({ id, name, nestedItems }) => (
<div className="tab-list__item" id={`accordWrapper_${id}`} key={id}>
<div className="tab-list__item-expander" onPointerUp={() => simulateClick(id)} />
<NavAccordion
onTransitionEnd={() => isExpanded(id) && scrollToExpanded(id)}
disabled={!nestedItems.length}
expanded={isExpanded(id)}
onChange={(_, expanded) => handleChange(expanded, id)}
>
<NavAccordionSummary id={`accordSummary_${id}`}>
<div className="tab-list__item-label">{localizeText(name)}</div>
</NavAccordionSummary>
{nestedItems.length && (
<NavAccordionDetails>
{nestedItems.map(({ id: nestedId, name, type, distance }, index) => (
<div
key={index}
className="g-flex tab-list__item-card"
onPointerUp={() => onNestedItemClick?.(nestedId)}
>
{type && (
<TransportIcon className="g-flex__item-fixed stop__icon" type={type} />
)}
<div className="g-flex__item">{localizeText(name)}</div>
<div className="g-flex__item-fixed distance">{formatDistance(distance)}</div>
</div>
))}
</NavAccordionDetails>
)}
</NavAccordion>
</div>
))}
</TouchScrollWrapper>
</div>
);
};

@ -0,0 +1,5 @@
.root {
position: absolute;
left: 312px;
bottom: 130px;
}

@ -0,0 +1,21 @@
import { HTMLAttributes } from 'react';
import { uuid } from '@mt/common-types';
import { AttractionShortPreview } from '@mt/components';
import { useGetAttractionDetails } from '../../hooks/useGetAttractionDetails';
import styles from './AttractionCard.module.css';
interface AttractionCardProps {
attractionId: uuid;
}
export function AttractionCard({ attractionId }: AttractionCardProps) {
const { isSuccess, data: attractionDetails } = useGetAttractionDetails(attractionId);
if (!(isSuccess && attractionDetails)) {
return null;
}
return <AttractionShortPreview className={styles.root} {...attractionDetails} />;
}

@ -0,0 +1,41 @@
import { ButtonBase } from '@mui/material';
import { FormattedMessage } from 'react-intl';
import { GerbIcon } from '../../../../icons';
import { HTMLAttributes } from 'react';
import { NavTabs } from '../../nav-widget';
interface Props extends HTMLAttributes<HTMLDivElement> {
onOpenTab: (tabName: NavTabs) => void;
}
export const HomeTab = ({ onOpenTab, ...props }: Props) => {
return (
<div className="g-flex-column" style={{ height: '100%' }} {...props}>
<div className="g-flex-column__item-fixed">
<GerbIcon />
</div>
<div className="g-flex-column__item g-flex-column g-flex--justify-center">
<ButtonBase className="tab-button" onPointerUp={() => onOpenTab('attractionsTab')}>
<FormattedMessage id="attractions" />
</ButtonBase>
<ButtonBase className="tab-button" onPointerUp={() => onOpenTab('stationsTab')}>
<FormattedMessage id="stops" />
</ButtonBase>
</div>
<div className="g-flex-column__item-fixed" style={{ textAlign: 'center' }}>
<img src="/assets/icons/company-logo.svg" alt="company-logo" />
<p className="slogan">
<FormattedMessage id="slogan" values={{ br: <br /> }} />
</p>
<p className="hashtag">
<FormattedMessage id="hashtag" />
</p>
</div>
</div>
);
};

@ -0,0 +1,29 @@
import type { LocalizedString, LocalizeTextFn } from '@mt/i18n';
const meterLabel: LocalizedString = {
ru: 'м',
en: 'm',
zh: '米',
};
const kilometerLabel: LocalizedString = {
ru: 'км',
en: 'km',
zh: '公里',
};
export function getDistanceFormatter(
localizeText: LocalizeTextFn
): (distance: number | undefined) => string {
return (distance) => {
if (!distance) {
return '';
}
if (distance < 1000) {
return `${distance} ${localizeText(meterLabel)}`;
}
return `${(distance / 1000).toFixed(2)} ${localizeText(kilometerLabel)}`;
};
}

@ -0,0 +1,3 @@
export * from './HomeTab/HomeTab';
export * from './AttractionCard/AttractionCard';
export * from './AccordionListTab/AccordionListTab';

@ -0,0 +1 @@
export * from './useGetAttractions';

@ -0,0 +1,24 @@
import { UseQueryResult, useQuery } from 'react-query';
import { uuid } from '@mt/common-types';
import { AttractionDetailsBE } from '../nav-widget.interface';
import { AttractionShortPreviewProps } from '@mt/components';
export function useGetAttractionDetails(
touristAttractionId: uuid
): UseQueryResult<AttractionShortPreviewProps> {
return useQuery(['getAttractionDetails', touristAttractionId], async () => {
const {
touristAttractionAddress: subtitle,
touristAttractionDescription: content,
touristAttractionName: title,
touristAttractionImageUrl: img,
}: AttractionDetailsBE = await fetch(
'https://localhost:8443/widgets/attraction-info/data-by-params?' +
new URLSearchParams({
touristAttractionId: touristAttractionId as string,
})
).then((res) => res.json());
return { img, title, subtitle, content };
});
}

@ -0,0 +1,28 @@
import { UseQueryResult, useQuery } from 'react-query';
import { Attraction } from '../nav-widget.interface';
import { useEventQuery } from '@mt/utils';
import { useEffect } from 'react';
export function useGetAttractions(): UseQueryResult<Attraction[]> {
const { data, isSuccess } = useEventQuery(
// 'getAttractionsEvents',
'/widgets/attraction-list/events',
['REFRESH_DATA']
);
const attractionQuery = useQuery(
'getAttractions',
async () =>
await fetch('https://localhost:8443/widgets/attraction-list/data')
.then((res) => res.json())
.then(({ touristAttractions }) => touristAttractions)
);
useEffect(() => {
if (isSuccess && data.length) {
attractionQuery.refetch();
}
}, [data, isSuccess]);
return attractionQuery;
}

@ -0,0 +1,28 @@
import { UseQueryResult, useQuery } from 'react-query';
import { Station } from '../nav-widget.interface';
import { useEventQuery } from '@mt/utils';
import { useEffect } from 'react';
export function useGetStations(): UseQueryResult<Station[]> {
const { data = [], isSuccess } = useEventQuery(
// 'getStationsEvents',
'/widgets/station-list/events',
['REFRESH_DATA']
);
const stationQuery = useQuery('getStations', async () => {
const { stations } = await fetch('https://localhost:8443/widgets/station-list/data').then(
(res) => res.json()
);
return stations;
});
useEffect(() => {
if (isSuccess && data.length) {
stationQuery.refetch();
}
}, [data, isSuccess]);
return stationQuery;
}

@ -0,0 +1,15 @@
import { HTMLAttributes } from 'react';
import { NavWidget } from './nav-widget';
import { useGetStations } from './hooks/useGetStations';
import { useGetAttractions } from './hooks';
export function NavWidgetContainer(props: HTMLAttributes<HTMLDivElement>) {
const { data: stations } = useGetStations();
const { data: attractions } = useGetAttractions();
if (!stations || !attractions) {
return null;
}
return <NavWidget {...props} attractions={attractions} stations={stations} />;
}

@ -0,0 +1,24 @@
import { uuid, TransportType } from '@mt/common-types';
import { LocalizedString } from '@mt/i18n';
export interface Station {
id: uuid;
name: LocalizedString;
type: TransportType;
distance?: number;
nearbyTouristAttractions: Attraction[];
}
export interface Attraction {
id: uuid;
name: LocalizedString;
distance?: number;
nearbyTransportStops: Station[];
}
export interface AttractionDetailsBE {
touristAttractionImageUrl: string;
touristAttractionName: LocalizedString;
touristAttractionAddress: LocalizedString;
touristAttractionDescription: LocalizedString;
}

@ -0,0 +1,169 @@
import styled from '@emotion/styled';
import { AccordionDetails, AccordionSummary, accordionSummaryClasses } from '@mui/material';
import MuiAccordion, { accordionClasses } from '@mui/material/Accordion';
export const NavAccordion = styled(MuiAccordion)`
box-shadow: none;
background-color: transparent;
color: #ffffff;
&:before {
content: '';
display: none;
}
&.${accordionClasses.expanded} {
margin: 0;
}
&.${accordionClasses.disabled} {
background-color: transparent;
}
`;
export const NavAccordionSummary = styled(AccordionSummary)`
pointer-events: none;
border-bottom: 1px solid #a6a6a6;
padding: 8px 16px;
line-height: 24px;
margin-top: 8px;
.${accordionSummaryClasses.content} {
margin: 0;
width: 100%;
}
&.${accordionSummaryClasses.expanded} {
min-height: 0;
.${accordionSummaryClasses.content} {
margin: 0;
}
}
&.${accordionSummaryClasses.disabled} {
opacity: 1;
}
`;
export const NavAccordionDetails = styled(AccordionDetails)`
padding: 12px 16px;
background: linear-gradient(90.36deg, #4e351f 0.27%, #59422d 47.89%, #65503c 99.65%);
`;
export const StyledNavWidget = styled.div`
height: 100%;
transition: all ease-in-out 0.3s;
&:has(.tab-container--opened) {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%),
rgba(128, 108, 89, 0.4);
}
.tab-button {
display: block;
padding: 8px 16px;
background: #ffffff;
color: #000;
margin: 12px 30px;
width: calc(100% - 60px);
border-radius: 10px;
}
.slogan {
font-style: italic;
font-weight: 400;
font-size: 16px;
line-height: 150%;
margin-top: 35px;
}
.hashtag {
margin-top: 180px;
margin-bottom: 32px;
}
.tab-container {
transform: translateY(100%);
height: calc(100% - 250px);
width: 100%;
transition: all ease-in-out 0.3s;
position: absolute;
bottom: 0;
background: linear-gradient(
113.51deg,
rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69%
),
#806c59;
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25);
border-radius: 10px 10px 0 0;
&.tab-container--opened {
transform: translateY(0);
filter: drop-shadow(0px -6px 13px rgba(0, 0, 0, 0.25));
}
}
.tab-title {
text-align: center;
font-weight: 700;
font-size: 16px;
line-height: 24px;
border-bottom: 1px solid #ffffff;
padding-top: 13px;
padding-bottom: 9px;
background: linear-gradient(
114deg,
rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69%
),
#806c59;
position: relative;
&:before {
content: '';
width: 98px;
height: 4px;
border-radius: 0 0 4px 4px;
background: #a6a6a6;
opacity: 0.35;
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
}
}
.tab-list__item {
position: relative;
}
.tab-list__item-expander {
position: absolute;
top: -8px;
left: 0;
width: 100%;
height: 55px;
z-index: 1;
cursor: pointer;
}
.tab-list__item-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
display: block;
}
.tab-list__item-card {
padding: 12px 0;
}
.stop__icon {
margin-right: 8px;
width: 18px;
}
.distance {
font-size: 12px;
color: #d9d9d9;
margin-left: 5px;
line-height: 185%;
}
`;

@ -0,0 +1,105 @@
import cn from 'classnames';
import { HTMLAttributes, ReactNode, useContext, useEffect, useMemo, useState } from 'react';
import { LocalizationContext, useServerLocalization } from '@mt/i18n';
import { Order, uuid } from '@mt/common-types';
import { AttractionCard, AccordionListTab, NestedItems } from './components';
import { Drawer, Icons } from '@mt/components';
import { Attraction, Station } from './nav-widget.interface';
import { HomeTab } from './components';
import { StyledNavWidget } from './nav-widget.styles';
export type NavTabs = 'stationsTab' | 'attractionsTab';
export interface NavWidgetProps extends HTMLAttributes<HTMLDivElement> {
stations: Station[];
attractions: Attraction[];
}
export function NavWidget({ stations, attractions }: NavWidgetProps) {
const { setLocale, locale } = useContext(LocalizationContext);
const localizeText = useServerLocalization();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [openedTab, setOpenedTab] = useState<NavTabs | null>(null);
const [attractionId, setAttractionId] = useState<uuid | null>(null);
const [attractionOrder, setAttractionOrder] = useState<Order>('asc');
const sortAttractionsBtn: ReactNode = useMemo(() => {
if (openedTab === 'attractionsTab') {
return (
<div
className={cn([{ 'order-btn-inverse': attractionOrder === 'desc' }, 'action-btn'])}
onPointerUp={() => setAttractionOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'))}
>
<Icons.SortIcon />
</div>
);
}
return <></>;
}, [openedTab, setAttractionOrder, attractionOrder]);
useEffect(() => {
if (!isOpen) {
setOpenedTab(null);
}
if (!isOpen || !openedTab) {
setAttractionId(null);
}
}, [isOpen, openedTab]);
const mappedStations = useMemo(() => {
return (stations ?? []).map(({ nearbyTouristAttractions, ...station }) => ({
...station,
nestedItems: nearbyTouristAttractions as NestedItems,
}));
}, [stations]);
const mappedAttractions = useMemo(() => {
return (attractions ?? [])
.map(({ nearbyTransportStops, ...attraction }) => ({
...attraction,
nestedItems: nearbyTransportStops as NestedItems,
}))
.sort(
(a, b) =>
localizeText(a.name).toLowerCase().localeCompare(localizeText(b.name).toLowerCase()) *
(attractionOrder === 'asc' ? 1 : -1)
);
}, [attractions, attractionOrder, locale]);
const handleExpandChange = (isExpanded: boolean, id: uuid) =>
setAttractionId(isExpanded ? id : null);
return (
<Drawer
className={cn({ 'nav-widget--opened': isOpen })}
isOpen={isOpen}
onToggle={setIsOpen}
onHomeBtnClick={() => setOpenedTab(null)}
onLocaleChange={setLocale}
actions={sortAttractionsBtn}
>
<StyledNavWidget>
<HomeTab onOpenTab={setOpenedTab} />
<AccordionListTab
titleId="stops"
isOpened={openedTab === 'stationsTab'}
items={mappedStations}
onNestedItemClick={setAttractionId}
/>
<AccordionListTab
titleId="attractions"
isOpened={openedTab === 'attractionsTab'}
items={mappedAttractions}
onExpandChange={handleExpandChange}
/>
</StyledNavWidget>
{attractionId && <AttractionCard attractionId={attractionId} />}
</Drawer>
);
}

@ -0,0 +1,76 @@
.operative-info-widget {
position: absolute;
bottom: 12px;
right: 32px;
}
.info-btn {
position: absolute;
bottom: 0;
right: 0;
width: 48px;
height: 48px;
border: 0;
padding: 0;
background: none;
cursor: pointer;
}
.popup {
position: absolute;
right: 48px; /*btn size*/
bottom: 0;
min-width: 371px;
padding: 16px;
margin-right: 16px;
border-radius: 10px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%),
rgba(179, 165, 152, 0.4);
box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12);
font-weight: 700;
font-size: 18px;
line-height: 150%;
color: #ffffff;
}
.popup-title {
margin-bottom: 4px;
}
.transfers-list,
.alerts-list {
margin: 0;
padding: 0;
}
.alerts-list {
margin-bottom: 20px;
}
.transfer-list-item,
.alert-list-item {
display: flex;
align-items: center;
gap: 9px;
list-style: none;
padding: 6px 12px;
}
.alert-name {
display: flex;
align-items: center;
}
.transfer-type-icon,
.transfer-type-icon svg,
.alert-icon {
width: 23px;
height: 23px;
}
.alert-icon {
margin-right: 6px;
}

@ -0,0 +1,15 @@
import { LocalizedString } from '@mt/i18n';
export interface AlertMessage {
key: string;
bgColor: string;
fontSize: number;
iconUrl: string;
order: number;
text: LocalizedString;
}
export interface AlertEvent {
['@type']: 'UPDATE_AVAILABLE_ALERTS';
msgs: AlertMessage[];
}

@ -0,0 +1,73 @@
import React, { useEffect, useState } from 'react';
import { Icons, TransportIcon } from '@mt/components';
import './OperativeInfoWidget.css';
import { useOperativeInfo } from './useOperativeInfo';
import { FormattedMessage } from 'react-intl';
import { useServerLocalization } from '@mt/i18n';
import { AlertMessage } from './alert.interface';
import { TransferItem } from './transfer.interface';
export function OperativeInfoWidget() {
const [isOpen, setIsOpen] = useState<boolean>(false);
const { transfers, alerts } = useOperativeInfo();
useEffect(() => {
setIsOpen(transfers.length > 0 || alerts.length > 0);
}, [transfers, alerts]);
return (
<div className="operative-info-widget">
<button className="info-btn" onPointerUp={() => setIsOpen(!isOpen)}>
<Icons.InfoBtn />
</button>
{isOpen && (
<div className="popup">
<AlertsList alerts={alerts} />
<TransfersList transfers={transfers} />
</div>
)}
</div>
);
}
function AlertsList({ alerts }: { alerts: AlertMessage[] }) {
const localizeText = useServerLocalization();
return alerts.length ? (
<ul className="alerts-list">
{alerts.map(({ key, bgColor, fontSize, iconUrl, text }) => (
<li
className="alert-list-item"
key={key}
style={{ backgroundColor: bgColor, fontSize: fontSize }}
>
<span className="alert-name">
{iconUrl && <img src={iconUrl} alt="" className="alert-icon" />}
{localizeText(text)}
</span>
</li>
))}
</ul>
) : null;
}
function TransfersList({ transfers }: { transfers: TransferItem[] }) {
return transfers.length ? (
<>
<div className="popup-title">
<FormattedMessage id="available-transfers" />
</div>
<ul className="transfers-list">
{transfers.map(({ type, label }) => (
<li className="transfer-list-item" key={type + label}>
<TransportIcon className="transfer-type-icon" type={type} />
<span className="transfer-name">{label}</span>
</li>
))}
</ul>
</>
) : null;
}

@ -0,0 +1,19 @@
import { Transfer } from '@front/types';
import { TransportType } from '@mt/common-types';
export interface PublicRoute {
type: TransportType;
// Public route number string (ex.: '43')
number: string;
}
export interface TransferEvent {
['@type']: 'UPDATE_AVAILABLE_TRANSFERS';
publicRoutes: PublicRoute[];
stations: Transfer[];
}
export interface TransferItem {
type: TransportType;
label: string;
}

@ -0,0 +1,83 @@
import { Dispatch, useEffect, useState } from 'react';
import { EventQueryData, useEventQuery } from '@mt/utils';
import { TransportType } from '@mt/common-types';
import { LocalizeTextFn, useServerLocalization } from '@mt/i18n';
import { TransferEvent, PublicRoute, TransferItem } from './transfer.interface';
import { AlertEvent, AlertMessage } from './alert.interface';
export const useOperativeInfo = () => {
const localizeText = useServerLocalization();
const [transfers, setTransfers] = useState<TransferItem[]>([]);
const [alerts, setAlerts] = useState<AlertMessage[]>([]);
const { data: events, isSuccess } = useEventQuery<TransferEvent | AlertEvent>(
'/widgets/operative-info/events',
['UPDATE_AVAILABLE_TRANSFERS', 'UPDATE_AVAILABLE_ALERTS']
);
useEffect(() => {
if (isSuccess && events.length) {
// prettier-ignore
const transferEvent = events.filter((e) => e['@type'] === 'UPDATE_AVAILABLE_TRANSFERS').at(-1) as EventQueryData<TransferEvent>;
// prettier-ignore
const alertEvent = events.filter((e) => e['@type'] === 'UPDATE_AVAILABLE_ALERTS').at(-1) as EventQueryData<AlertEvent>;
updateTransfers(transferEvent, setTransfers, localizeText);
updateAlerts(alertEvent, setAlerts);
}
}, [events, isSuccess]);
return { transfers, alerts };
};
function updateTransfers(
transferEvent: EventQueryData<TransferEvent>,
setTransfers: Dispatch<TransferItem[]>,
localizeText: LocalizeTextFn
) {
if (transferEvent) {
const { publicRoutes, stations } = transferEvent as TransferEvent;
const newTransfers: TransferItem[] = [
...mapPublicRoutesToTransfers(publicRoutes),
...stations.map(({ type, name }) => ({
type,
label: localizeText(name),
})),
];
setTransfers(newTransfers);
}
}
function updateAlerts(alertEvent: EventQueryData<AlertEvent>, setAlerts: Dispatch<AlertMessage[]>) {
if (alertEvent) {
const { msgs } = alertEvent as AlertEvent;
const newAlerts: AlertMessage[] = [...msgs]
.sort((a, b) => a.order - a.order)
.map((msg) => ({
...msg,
key: crypto.randomUUID(),
}));
setAlerts(newAlerts);
}
}
function mapPublicRoutesToTransfers(routes: PublicRoute[]): TransferItem[] {
const routeMap = routes.reduce((acc: Partial<Record<TransportType, string[]>>, item) => {
const { type, number } = item;
acc[type] = [...(acc[type] ?? []), number];
return acc;
}, {});
return Object.entries(routeMap).flatMap(([type, numbers]) => {
return {
type,
label: numbers.join(', '),
} as TransferItem;
});
}

@ -0,0 +1,29 @@
export type WeatherTypes =
| 'CLOUDY'
| 'PARTLYCLOUDY'
| 'RAINY'
| 'SNOW'
| 'SNOWY'
| 'SUNNY'
| 'THUNDER';
export interface WeatherDayShortProps {
condition: WeatherTypes | null;
temperature: number | null;
}
export type WeatherDayProps = WeatherDayShortProps & {
humidity: number | null;
windSpeed: number | null;
};
export interface WeatherForecastsProps {
weatherInfo: WeatherDayShortProps;
}
export interface WeatherWidgetData {
forecasts: WeatherForecastsProps[];
weatherInfo: WeatherDayProps;
}
export type WeatherDayRow = WeatherDayShortProps & {
weekday: number;
};

@ -0,0 +1,37 @@
.locale-switcher {
position: relative;
display: inline-block;
}
.locale-switcher__button {
width: 48px;
height: 48px;
display: block;
cursor: pointer;
background: transparent;
border: none;
padding: 0;
}
.locale-switcher__options {
display: flex;
gap: 10px;
}
.locale-switcher__option {
width: 48px;
height: 48px;
cursor: pointer;
background: #ffffff;
border: none;
border-radius: 50%;
text-transform: uppercase;
font-weight: bold;
padding: 0;
font-size: 22px;
}
.locale-switcher__option.selected {
background-color: #cccccc;
color: #ffffff;
}

@ -0,0 +1,45 @@
import React, { useCallback, useState } from 'react';
// TODO: resolve circular deps (probably we should move icons to a separate lib)
import { Icons } from '@mt/components';
import { Locale, localesMap } from '../i18n.interface';
import './LocaleSwitcher.css';
interface LocaleSwitcherProps {
onLocaleChange: (locale: Locale) => void;
}
export const LocaleSwitcher = ({ onLocaleChange }: LocaleSwitcherProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedLocale, setSelectedLocale] = useState<Locale>('ru');
const handleLocaleChange = useCallback(
(locale: Locale) => {
setSelectedLocale(locale);
setIsOpen(false);
onLocaleChange(locale);
},
[isOpen]
);
return (
<div className="locale-switcher">
{!isOpen ? (
<button className="locale-switcher__button" onPointerUp={() => setIsOpen(!isOpen)}>
<Icons.I18NIcon />
</button>
) : (
<div className="locale-switcher__options">
{Object.entries(localesMap).map(([label, locale]) => (
<button
key={locale}
className={`locale-switcher__option ${selectedLocale === locale ? 'selected' : ''}`}
onPointerUp={() => handleLocaleChange(locale)}
>
{label}
</button>
))}
</div>
)}
</div>
);
};

10
src/preview /i18n/en.json Normal file

@ -0,0 +1,10 @@
{
"support-of-the-government-of-spb": "With the support of the Government of St. Petersburg",
"attractions": "Attractions",
"stops": "Stops",
"available-transfers": "Available transfers:",
"hashtag": "#UsAlongTheWay",
"slogan": "Preserving history,{br}moving into the future.",
"loading": "Loading...",
"close": "Close"
}

@ -0,0 +1,10 @@
// and don't forget to add new locale here as well
export type Locale = 'en' | 'ru' | 'zh';
export const localesMap: Record<string, Locale> = {
ru: 'ru',
: 'zh',
en: 'en',
};
export type LocalizedString = Record<Locale, string>;

@ -0,0 +1,5 @@
export * from './i18n.interface';
export * from './language-loader';
export * from './useServerLocalization';
export * from './localization-context';
export * from './LocaleSwitcher/LocaleSwitcher';

@ -0,0 +1,15 @@
import en from './en.json';
import ru from './ru.json';
import zh from './zh.json';
import { Locale } from './i18n.interface';
const languages = {
en,
ru,
zh,
// Add more language imports here
};
export function getLanguage(locale: Locale): Record<string, string> {
// Return the language object for the specified locale
return languages[locale] || languages.ru; // Default to Russian if the locale is not found
}

@ -0,0 +1,25 @@
import React, { createContext, ReactNode, useState } from 'react';
import { IntlProvider } from 'react-intl';
import { Locale } from './i18n.interface';
import { getLanguage } from './language-loader';
export const LocalizationContext = createContext<{
locale: Locale;
setLocale: (locale: Locale) => void;
}>({
locale: 'ru',
// eslint-disable-next-line @typescript-eslint/no-empty-function
setLocale: (locale: Locale) => {},
});
export const LocalizationProvider = ({ children }: { children: ReactNode }) => {
const [locale, setLocale] = useState<Locale>('ru');
return (
<LocalizationContext.Provider value={{ locale, setLocale }}>
<IntlProvider locale={locale} messages={getLanguage(locale)}>
{children}
</IntlProvider>
</LocalizationContext.Provider>
);
};

10
src/preview /i18n/ru.json Normal file

@ -0,0 +1,10 @@
{
"support-of-the-government-of-spb": "При поддержке Правительства Санкт-Петербурга",
"attractions": "Достопримечательности",
"stops": "Остановки",
"available-transfers": "Доступны пересадки:",
"hashtag": "#ВсемПоПути",
"slogan": "Сохраняя историю,{br}движемся в будущее.",
"loading": "Загрузка...",
"close": "Закрыть"
}

@ -0,0 +1,19 @@
import { useContext } from 'react';
import { LocalizationContext } from './localization-context';
import { Locale, LocalizedString } from './i18n.interface';
export type LocalizeTextFn = (content: LocalizedString | undefined) => string;
export function useServerLocalization() {
const { locale } = useContext(LocalizationContext);
const localizeText: LocalizeTextFn = (content) => {
if (content && typeof content === 'object' && locale in content) {
return content[locale as Locale];
} else {
return '';
}
};
return localizeText;
}

10
src/preview /i18n/zh.json Normal file

@ -0,0 +1,10 @@
{
"support-of-the-government-of-spb": "在聖彼得堡政府的支持下",
"attractions": "景點",
"stops": "停止",
"available-transfers": "可用的轉移:",
"hashtag": "#我们在路上",
"slogan": "保存历史、{br}迈向未来。",
"loading": "載入中...",
"close": "關閉"
}

@ -0,0 +1,39 @@
.attraction-card {
height: 415px;
width: 315px;
background: linear-gradient(
113.51deg,
rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69%
),
#806c59;
border-radius: 10px;
overflow: hidden;
color: #ffffff;
}
.attraction-card__content {
padding: 12px;
}
.attraction-card__title {
margin: 8px 0 0 0;
}
.attraction-card__text {
margin: 30px 0 0 0;
white-space: pre-wrap;
}
.attraction-card__subtitle {
margin: 8px 0 0 0;
font-weight: 400;
}
.attraction-card__image {
min-width: 100%;
max-height: 50%;
padding: 2px;
border-radius: 10px 10px 0 0;
}

@ -0,0 +1,43 @@
import './AttractionShortPreview.css';
import { LocalizedString, useServerLocalization } from '@mt/i18n';
import classNames from 'classnames';
import { TouchScrollWrapper } from '../TouchScrollWrapper/TouchScrollWrapper';
import { HTMLAttributes } from 'react';
export interface AttractionShortPreviewProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
img: string;
title: LocalizedString;
subtitle: LocalizedString;
content: LocalizedString;
}
export function AttractionShortPreview({
img,
title,
subtitle,
content,
className,
...props
}: AttractionShortPreviewProps) {
const localizeText = useServerLocalization();
return (
<div className={classNames(className, 'attraction-card g-flex-column')} {...props}>
{img && <img className="attraction-card__image" src={img} alt={localizeText(title)} />}
<TouchScrollWrapper className="g-flex-column__item">
<div className="attraction-card__content">
<h4 className="attraction-card__title">{localizeText(title)}</h4>
<h5 className="attraction-card__subtitle">{localizeText(subtitle)}</h5>
<p
className="attraction-card__text"
dangerouslySetInnerHTML={{ __html: localizeText(content) }}
/>
</div>
</TouchScrollWrapper>
</div>
);
}

@ -0,0 +1,126 @@
.widget-container {
width: 545px;
height: var(--attraction-widget-container-height, 100%);
max-height: calc(100% - 90px);
color: #ffffff;
background: #806c59;
border: 2px solid #806c59;
border-radius: 10px;
}
.widget-content {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
width: 100%;
height: 100%;
overflow: hidden;
}
.widget-slide {
position: relative;
display: none;
top: 0;
left: 0;
flex-direction: column;
justify-content: flex-start;
align-items: center;
width: 100%;
}
.widget-slide.active,
.widget-slide.preview {
display: flex;
flex: 1;
overflow: auto;
}
.widget-media {
width: 100%;
height: auto;
max-height: 644px;
}
.view-container {
border-radius: 8px 8px 0 0;
}
.widget-header {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%),
rgba(179, 165, 152, 0.4);
box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12);
width: 100%;
padding: 9px 16px;
font-weight: 700;
font-size: 24px;
line-height: 120%;
}
.widget-text {
width: 100%;
align-self: self-start;
padding: 16px;
font-weight: 400;
font-size: 18px;
line-height: 150%; /* or 27px */
opacity: 0;
transition: opacity 0.5s ease-in-out;
user-select: none;
word-break: break-word;
white-space: pre-wrap;
}
.widget-text p {
margin: 0;
}
.widget-text.preview {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-weight: 700;
font-size: 48px;
line-height: 120%;
text-align: center;
}
.widget-text.active {
opacity: 1;
}
.widget-titles {
display: flex;
height: 50px;
justify-content: space-evenly;
align-items: center;
width: 100%;
margin: 5px 0 0 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%),
rgba(179, 165, 152, 0.4);
box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12);
border-radius: 0 0 10px 10px;
padding: 12px 0;
}
.widget-title {
font-weight: 400;
font-size: 18px;
line-height: 21px;
cursor: pointer;
user-select: none;
width: 100px;
text-align: center;
}
.widget-title.active {
font-weight: bold;
text-decoration: underline;
text-underline-offset: 5px;
}
.widget-title.preview {
display: none;
}

@ -0,0 +1,111 @@
import React, { HTMLAttributes, useEffect } from 'react';
import { useServerLocalization } from '@mt/i18n';
import cn from 'classnames';
import { useSwipeable } from 'react-swipeable';
import { ArticleBase } from '@mt/common-types';
import './AttractionWidget.css';
import { usePrevious } from '@mt/utils';
import { AttractionMedia } from './media/AttractionMedia';
import { useStore } from 'react-admin';
import { TouchScrollWrapper } from '../TouchScrollWrapper/TouchScrollWrapper';
export interface AttractionsWidgetProps extends HTMLAttributes<HTMLElement> {
articles: ArticleBase[];
isIdleMode: boolean;
isPreviewOnly?: boolean;
}
export const ATTRACTION_WIDGET_TABINDEX_STORE_KEY = 'attractions.widget.tabindex';
export function AttractionWidget({
articles,
isIdleMode,
isPreviewOnly = false,
...props
}: AttractionsWidgetProps) {
const [activeIndex, setActiveIndex] = useStore(ATTRACTION_WIDGET_TABINDEX_STORE_KEY, 0);
const prevArticles = usePrevious<ArticleBase[]>(articles) || [];
const localizeText = useServerLocalization();
const swipeHandlers = useSwipeable({
onSwipedLeft: ({ event }) => {
event.preventDefault();
setActiveIndex((activeIndex) => (activeIndex + 1) % articles.length);
},
onSwipedRight: ({ event }) => {
event.preventDefault();
setActiveIndex((activeIndex) => (activeIndex - 1 + articles.length) % articles.length);
},
swipeDuration: 500,
preventScrollOnSwipe: true,
trackMouse: true,
});
const handleClick = (index: number) => {
setActiveIndex(index);
document.querySelector('.widget-text.active')!.scrollTop = 0;
};
useEffect(() => setActiveIndex(activeIndex), [activeIndex]);
useEffect(() => {
if (
!isPreviewOnly &&
(isIdleMode || JSON.stringify(prevArticles) !== JSON.stringify(articles))
) {
setActiveIndex(0);
}
// admin specific case: during edit we removed active article
if (prevArticles?.length > articles?.length) {
setActiveIndex(0);
}
}, [isPreviewOnly, isIdleMode, articles]);
return (
<div className="widget-container g-flex-column__item-fixed" {...props}>
<div className="widget-content">
{articles?.map((article, index) => (
<div
key={index}
className={`widget-slide ${index === activeIndex ? 'active' : ''}`}
onPointerUp={() => handleClick(index)}
>
<div className="widget-media">
<AttractionMedia media={article.media} />
</div>
{index !== 0 && <div className="widget-header">{localizeText(articles[0].text)}</div>}
<TouchScrollWrapper
className={cn('widget-text', {
active: index === activeIndex,
preview: article.isPreview,
})}
>
<div
dangerouslySetInnerHTML={{ __html: localizeText(article.text) }}
{...swipeHandlers}
/>
</TouchScrollWrapper>
</div>
))}
<div className="widget-titles">
{articles?.map((article, index) => (
<div
key={`title-${index}`}
className={cn('widget-title', {
active: index === activeIndex,
preview: article.isPreview,
})}
onPointerUp={() => handleClick(index)}
>
{localizeText(article.name)}
</div>
))}
</div>
</div>
</div>
);
}

@ -0,0 +1,52 @@
.widget-image,
.widget-video,
.widget-3d-model {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
display: block;
border-radius: 8px 8px 0 0;
}
.widget-3d-model {
height: 350px;
}
.widget-media__wrapper {
position: relative;
/*TODO: it worth to investigate it further... quite weird behavior of */
box-sizing: content-box !important;
}
.fullscreen-photo-sphere-btn,
.fullscreen-3d-btn {
width: 20px;
height: 20px;
position: absolute;
right: 10px;
bottom: 10px;
cursor: pointer;
z-index: 100;
opacity: 0.7;
}
.media-with-watermark {
position: relative;
}
.watermark {
position: absolute;
top: 10px;
left: 10px;
width: 50px;
height: auto;
}
.psv-autorotate-button {
display: block !important;
}
.psv-menu-button {
display: none !important;
}

@ -0,0 +1,34 @@
import { Media } from '@mt/common-types';
import { ImageMedia } from './ImageMedia';
import { VideoMedia } from './VideoMedia';
import { PhotoSphereMedia } from './PhotoSphereMedia';
import { Object3DMedia } from './Object3DMedia';
import { memo } from 'react';
export const AttractionMedia = memo(
({ media }: { media: Media }) => {
const { type, url, watermarkUrl } = media;
if (!url) return null;
switch (type) {
case 'IMAGE':
return <ImageMedia url={url} alt={media.url} watermarkUrl={watermarkUrl} />;
case 'VIDEO':
return <VideoMedia url={url} watermarkUrl={watermarkUrl} />;
case 'PHOTO_SPHERE':
return <PhotoSphereMedia url={url} watermarkUrl={watermarkUrl} />;
case 'OBJECT_3D':
return <Object3DMedia url={url} watermarkUrl={watermarkUrl} />;
default:
return null;
}
},
({ media }, { media: newMedia }) => {
return (
media.url === newMedia.url &&
media.watermarkUrl === newMedia.watermarkUrl &&
media.type === newMedia.type
);
}
);

@ -0,0 +1,25 @@
import cn from 'classnames';
import React from 'react';
import './AttractionMedia.css';
interface ImageMediaProps {
url: string;
alt: string;
watermarkUrl?: string;
}
export const ImageMedia = ({ url, alt, watermarkUrl }: ImageMediaProps) => (
<>
<img
src={url}
alt={alt}
className={cn('widget-image', {
'media-with-watermark': watermarkUrl !== null,
})}
/>
{watermarkUrl && (
<img src={watermarkUrl} alt="Watermark" className="watermark" />
)}
</>
);

@ -0,0 +1,50 @@
import cn from 'classnames';
import React, { useEffect, useState } from 'react';
import './AttractionMedia.css';
import ModelViewer from '../../model-viewer/ModelViewer';
import { Icons, useLightboxContext } from '@mt/components';
import { Object3DLightboxData } from '@mt/common-types';
interface Object3DMediaProps {
url: string;
watermarkUrl?: string;
}
export const Object3DMedia = ({ url, watermarkUrl }: Object3DMediaProps) => {
// prettier-ignore
const { setData, openLightbox } = useLightboxContext<Object3DLightboxData>();
const [autoRotate, setAutoRotate] = useState(true);
const handle3DFullscreenOpen = () => {
setAutoRotate(false);
setData({
type: 'OBJECT_3D',
modelUrl: url,
watermarkUrl,
});
openLightbox();
};
useEffect(() => {
setAutoRotate(true);
}, [url]);
return (
<div className="widget-media__wrapper">
<div
className={cn('widget-3d-model', {
'media-with-watermark': watermarkUrl !== null,
})}
>
<ModelViewer key={url} pathToModel={url} autoRotate={autoRotate} />
{watermarkUrl && <img src={watermarkUrl} alt="Watermark" className="watermark" />}
</div>
<Icons.FullscreenIcon
className="fullscreen-3d-btn"
onPointerUp={() => handle3DFullscreenOpen()}
/>
</div>
);
};

@ -0,0 +1,57 @@
import cn from 'classnames';
import React, { useRef } from 'react';
import { ReactPhotoSphereViewer } from 'react-photo-sphere-viewer';
import { PhotoSphereLightboxData } from '@mt/common-types';
import './AttractionMedia.css';
import { useLightboxContext } from '../../lightbox';
import { Icons } from '@mt/components';
interface PhotoSphereMediaProps {
url: string;
watermarkUrl?: string;
}
export const PhotoSphereMedia = ({ url, watermarkUrl }: PhotoSphereMediaProps) => {
// prettier-ignore
const { setData, openLightbox } = useLightboxContext<PhotoSphereLightboxData>();
// react-photo-sphere-viewer doesn't have exported types, so here's a bit of a hardcoded piece
const photoSphereRef = useRef<{ stopAutoRotate: () => void }>();
const handlePhotoSphereFullscreenOpen = () => {
photoSphereRef.current?.stopAutoRotate();
setData({
type: 'PHOTO_SPHERE',
imageUrl: url,
watermarkUrl,
});
openLightbox();
};
return (
<div className="widget-media__wrapper">
<ReactPhotoSphereViewer
ref={photoSphereRef}
key={url}
src={url}
height={'350px'}
width={'100%'}
container={cn('widget-media', {
'media-with-watermark': watermarkUrl !== null,
})}
moveInertia={false}
mousemove={true}
navbar={['autorotate', 'zoom']}
keyboard={false}
loadingTxt="Загрузка..."
/>
{watermarkUrl && <img src={watermarkUrl} alt="Watermark" className="watermark" />}
{/* the following is a workaround to open lightbox-like preview in the middle of the screen instead of the real fullscreen */}
<Icons.FullscreenIcon
className="fullscreen-photo-sphere-btn"
onPointerUp={() => handlePhotoSphereFullscreenOpen()}
/>
</div>
);
};

@ -0,0 +1,26 @@
import cn from 'classnames';
import React from 'react';
import './AttractionMedia.css';
interface VideoMediaProps {
url: string;
watermarkUrl?: string;
}
export const VideoMedia = ({ url, watermarkUrl }: VideoMediaProps) => (
<>
<video
src={url}
className={cn('widget-video', {
'media-with-watermark': watermarkUrl !== null,
})}
autoPlay
loop
muted
/>
{watermarkUrl && (
<img src={watermarkUrl} alt="Watermark" className="watermark" />
)}
</>
);

@ -0,0 +1,47 @@
// TODO: rewrite as css module
import styled from '@emotion/styled';
export const StyledDrawer = styled.div`
z-index: 1000;
position: absolute;
width: 290px;
height: 100%;
transition: all ease-in-out 0.3s;
transform: translateX(-100%);
color: #fff;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%),
#806c59;
&.nav-widget--opened {
transform: translateX(0);
.toggle-btn {
transform: rotate(0);
}
}
.actions {
position: absolute;
bottom: 12px;
left: 310px;
}
.action-btn {
width: 48px;
height: 48px;
display: block;
cursor: pointer;
margin-right: 16px;
}
.toggle-btn {
transform: rotate(180deg);
}
.order-btn-inverse {
transform: scale(1, -1);
}
`;

@ -0,0 +1,47 @@
import cn from 'classnames';
import { HTMLAttributes, ReactNode } from 'react';
import { StyledDrawer } from './Drawer.styles';
import { Icons } from '@mt/components';
import { Locale, LocaleSwitcher } from '@mt/i18n';
export interface DrawerProps extends HTMLAttributes<HTMLDivElement> {
onToggle: (isOpened: boolean) => void;
isOpen: boolean;
onHomeBtnClick?: () => void;
onLocaleChange: (locale: Locale) => void;
actions?: ReactNode;
}
// TODO: consider refactoring - drawer and controls should be separated
export function Drawer({
children,
isOpen,
onToggle,
onHomeBtnClick,
onLocaleChange,
actions,
...props
}: DrawerProps) {
return (
<StyledDrawer className={cn('g-flex-column', { 'nav-widget--opened': isOpen })} {...props}>
{children}
<div className="g-flex actions">
<div className="action-btn toggle-btn" onPointerUp={() => onToggle(!isOpen)}>
<Icons.ArrowBtn />
</div>
{isOpen && (
<div className="action-btn" onPointerUp={() => onHomeBtnClick?.()}>
<Icons.HomeBtn />
</div>
)}
{actions}
<LocaleSwitcher onLocaleChange={onLocaleChange} />
</div>
</StyledDrawer>
);
}

@ -0,0 +1 @@
export { Drawer } from './Drawer';

@ -0,0 +1,244 @@
import React, { createContext, useState, useContext, ReactNode, useMemo, useEffect } from 'react';
import { geoMercator } from 'd3-geo';
import { Coordinates, Track, uuid } from '@mt/common-types';
import { useNearStation, usePassedTrackIndex } from './hooks';
import { AttractionGroup, MapData, StationOnMap } from './map-widget.interface';
import { getMapPoint } from './utils';
import { EMPTY_SETTING_VALUE, zeroCoordinates } from './map-widget.constant';
import { MapSettings, MapWidgetContextType } from './map-widget-context.interface';
import { useForm } from 'react-hook-form';
export const mapCanvasProps = {
style: {
width: '100%',
height: '100%',
},
width: 500,
height: 400,
};
// prettier-ignore
export const MapWidgetContext = createContext<MapWidgetContextType | undefined>(undefined);
export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
const [track, setTrack] = useState<Track | null>(null);
const [stations, setStations] = useState<StationOnMap[]>([]);
const [updatedStationIds, setUpdatedStationIds] = useState<uuid[]>([]);
const [attractionGroups, setAttractionGroups] = useState<AttractionGroup[]>([]);
const [rotateAngle, setRotateAngle] = useState<number>(0);
const [scale, setScale] = useState<number>(0);
const [fullScale, setFullScale] = useState<number>(0);
const [zoomedScale, setZoomedScale] = useState<number>(0);
const [center, setCenter] = useState(zeroCoordinates);
const [baseCenter, setBaseCenter] = useState(zeroCoordinates);
const [currentPosition, setCurrentPosition] = useState<Coordinates | null>(null);
const [isEditMode, setIsEditMode] = useState<boolean>(false);
const [isDragMode, setIsDragMode] = useState<boolean>(false);
const [initialSettingsData, setInitialSettingsData] = useState<MapSettings>(EMPTY_SETTING_VALUE);
const [isSettingsDataChanged, setIsSettingsDataChanged] = useState<boolean>(false);
const isMapDataChanged = useMemo(
() => isSettingsDataChanged || updatedStationIds.length > 0,
[isSettingsDataChanged, updatedStationIds]
);
const stationsMap = useMemo(
() => new Map(stations.map((station) => [station.id, station])),
[stations]
);
const middleTrackCoordinates: Coordinates | null = useMemo(() => {
if (!track?.length) {
return null;
}
const middleTrackIndex = Math.floor(track.length / 2);
return track[middleTrackIndex];
}, [track]);
const settingsForm = useForm({
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: initialSettingsData,
});
useEffect(() => settingsForm.reset(initialSettingsData), [initialSettingsData]);
const onMapDataFetched = (data: MapData) => {
setTrack(data.trackPoints);
setStations(data.stationsOnMap);
setAttractionGroups(data.touristAttractionGroupsOnMap);
setRotateAngle(data.mapRotateAngle);
setCenter(data.centerOfMapPoint);
setBaseCenter(data.centerOfMapPoint);
setScale(data.fullMapScale);
setFullScale(data.fullMapScale);
setZoomedScale(data.zoomedMapScale);
setInitialSettingsData({
rotateAngle: data.mapRotateAngle,
center: data.centerOfMapPoint,
fullScale: data.fullMapScale,
zoomedScale: data.zoomedMapScale,
});
setIsSettingsDataChanged(false);
setUpdatedStationIds([]);
};
const onSettingsFormChange = () => {
const formData = settingsForm.getValues();
const { center, rotateAngle, fullScale, zoomedScale, currentStationId } = formData;
setBaseCenter(center);
setRotateAngle(rotateAngle);
setFullScale(fullScale);
setZoomedScale(zoomedScale);
if (currentStationId) {
const { pointOnMap } = stationsMap.get(currentStationId) as StationOnMap;
setCenter(pointOnMap);
setScale(zoomedScale);
setCurrentPosition(pointOnMap);
setIsDragMode(false);
} else {
setCenter(center);
setScale(fullScale);
setCurrentPosition(middleTrackCoordinates);
}
updateMapDataChanged(formData);
};
const onMapCenterMoved = (center: Coordinates) => {
setBaseCenter(center);
setCenter(center);
settingsForm.setValue('center', center, { shouldDirty: true });
updateMapDataChanged(settingsForm.getValues());
};
const updateMapDataChanged = (data: MapSettings) => {
const { rotateAngle, center, fullScale, zoomedScale } = data;
setIsSettingsDataChanged(
JSON.stringify({
rotateAngle,
center,
fullScale,
zoomedScale,
}) !== JSON.stringify(initialSettingsData)
);
};
const onStationUpdate: MapWidgetContextType['onStationUpdate'] = (
stationId,
{ labelOffset, labelAlignment }
) => {
const updatedStation = {
...(stationsMap.get(stationId) as StationOnMap),
...(labelOffset && { labelOffset }),
...(labelAlignment && { labelAlignment }),
};
setStations((stations) =>
stations.map((station) => (station.id === stationId ? updatedStation : station))
);
setUpdatedStationIds((ids) => [...ids, stationId]);
};
const getUpdatedStations = () => {
return updatedStationIds.reduce((acc: Record<uuid, any>, id: uuid) => {
const { labelAlignment, labelOffset } = stationsMap.get(id) as StationOnMap;
acc[id] = {
textAlignment: labelAlignment,
mapOffsets: labelOffset,
};
return acc;
}, {});
};
const projection = useMemo(() => {
const { width, height } = mapCanvasProps;
return geoMercator()
.translate([width / 2, height / 2])
.center(getMapPoint(center))
.scale(scale);
}, [center, scale]);
const { passedTrackIndex } = usePassedTrackIndex(track, currentPosition);
const { currentStation, nextStation, isOnStation } = useNearStation(
currentPosition,
stations,
passedTrackIndex
);
// Bind map center and zoom to currentStation in not EditMode
useEffect(() => {
if (isEditMode) {
return;
}
if (currentStation) {
const { pointOnMap } = currentStation;
setCenter(pointOnMap);
setScale(zoomedScale);
} else {
setCenter(baseCenter);
setScale(fullScale);
}
}, [currentStation]);
const contextValue = {
track,
center,
rotateAngle,
projection,
attractionGroups,
stations,
currentPosition,
middleTrackCoordinates,
passedTrackIndex,
currentStation,
isOnStation,
nextStation,
setCurrentPosition,
isDragMode,
setIsDragMode,
isEditMode,
setIsEditMode,
onMapDataFetched,
settingsForm,
onSettingsFormChange,
onMapCenterMoved,
onStationUpdate,
getUpdatedStations,
isMapDataChanged,
};
return <MapWidgetContext.Provider value={contextValue}>{children}</MapWidgetContext.Provider>;
};
export const useMapWidgetContext = function (): MapWidgetContextType {
const context = useContext(MapWidgetContext);
if (!context) {
throw new Error('useMapWidgetContext must be used within a MapWidgetProvider');
}
return context;
};

@ -0,0 +1,25 @@
import { TrackAttractions, TrackLine, TrackStations, TramMarker } from '../index';
import { getMapPoint } from '../../utils';
import { useMapWidgetContext } from '../../MapWidgetContext';
export const MapContent = () => {
const { rotateAngle, isEditMode, currentPosition, currentStation, nextStation } =
useMapWidgetContext();
return (
<g className="g-transform-origin__center" style={{ transform: `rotate(${rotateAngle}deg)` }}>
<TrackLine />
<TrackAttractions />
<TrackStations />
{!isEditMode && currentPosition && nextStation && (
<TramMarker
coordinates={getMapPoint(currentPosition)}
nextStopPoint={(currentStation ?? nextStation).pointOnMap}
/>
)}
</g>
);
};

@ -0,0 +1,4 @@
.mapWidget {
position: relative;
z-index: 100;
}

@ -0,0 +1,50 @@
import { ComposableMap, ZoomableGroup, ZoomableGroupProps } from 'react-simple-maps';
import styles from './MapWidget.module.css';
import { mapCanvasProps, useMapWidgetContext } from '../../MapWidgetContext';
import { useState } from 'react';
import { MapContent } from './MapContent';
// default coordinates for 3a route: 59.943, 30.331
export const MapWidget = () => {
const { onMapCenterMoved, projection, isDragMode, rotateAngle } = useMapWidgetContext();
const [key, setKey] = useState(42);
const handleMoveEnd: ZoomableGroupProps['onMoveEnd'] = (e, d3Zoom) => {
const { PI, cos, sin } = Math;
const { x, y } = d3Zoom.transform;
const { width, height } = mapCanvasProps;
const alpha = (-rotateAngle * PI) / 180;
const x1 = x * cos(alpha) - y * sin(alpha);
const y1 = x * sin(alpha) + y * cos(alpha);
const cX = width / 2 - x1;
const cY = height / 2 - y1;
const [lon, lat] = projection.invert?.([cX, cY]) ?? [0, 0];
onMapCenterMoved({ lon, lat });
setKey(-key);
};
return (
<ComposableMap
// Cast to any need due to error in react-simple-maps typings
projection={projection as any}
className={styles.mapWidget}
{...mapCanvasProps}
>
<ZoomableGroup
key={key}
center={projection.center()}
onMoveEnd={handleMoveEnd}
filterZoomEvent={() => isDragMode}
minZoom={1}
maxZoom={1}
>
<MapContent />
</ZoomableGroup>
</ComposableMap>
);
};

@ -0,0 +1 @@
export * from './MapWidget';

@ -0,0 +1,41 @@
.markerLarge,
.markerSmall {
overflow: visible;
}
.markerLarge {
width: 23px;
}
.markerLarge .counter {
transform: translate(30%, 15%);
}
.markerSmall {
width: 15px;
}
.markerSmall .counter {
transform: translate(50%, -25%);
}
.icon {
width: 100%;
}
.counter {
position: absolute;
top: 0;
right: 0;
width: 8px;
height: 8px;
line-height: 8px;
text-align: center;
border-radius: 50%;
background-color: #896f58;
font-size: 0.3rem;
font-weight: 700;
color: #ffffff;
}

@ -0,0 +1,32 @@
import { Marker, Point } from 'react-simple-maps';
import { Icons } from '@mt/components';
import { AttractionGroupIconSizeType } from '@mt/common-types';
import styles from './AttractionMarker.module.css';
import cn from 'classnames';
interface Props {
coordinates: Point;
rotate: number;
size: AttractionGroupIconSizeType;
counter?: number;
}
export const AttractionMarker = ({ coordinates, counter = 0, rotate, size }: Props) => {
return (
<Marker coordinates={coordinates}>
<foreignObject
className={cn({
[styles.markerLarge]: size === 'LARGE',
[styles.markerSmall]: size === 'SMALL',
})}
>
<div className="g-transform-origin__center" style={{ transform: `rotate(${rotate}deg)` }}>
<Icons.AttractionIcon className={styles.icon} />
{counter > 1 && <div className={styles.counter}>{counter}</div>}
</div>
</foreignObject>
</Marker>
);
};

@ -0,0 +1,25 @@
import { AttractionMarker } from './AttractionMarker';
import { getMapPoint } from '../../utils';
import { useMapWidgetContext } from '../../MapWidgetContext';
export const TrackAttractions = () => {
const { attractionGroups, rotateAngle } = useMapWidgetContext();
return (
<>
{attractionGroups.map((group) => (
<AttractionMarker
key={
group.touristAttractionsOnMap[0]?.id ||
`${group.pointOnMap.lat}:${group.pointOnMap.lon}`
}
coordinates={getMapPoint(group.pointOnMap)}
// Inverse angle to compensate map rotation
rotate={-rotateAngle}
counter={group.touristAttractionsOnMap.length}
size={group.iconSize}
/>
))}
</>
);
};

@ -0,0 +1 @@
export * from './TrackAttractions';

@ -0,0 +1,32 @@
import { useMemo } from 'react';
import { Line, Point } from 'react-simple-maps';
import { getMapPoint } from '../utils';
import { useMapWidgetContext } from '../MapWidgetContext';
import { zeroCoordinates } from '../map-widget.constant';
const passedTrackColor = '#ed1c24';
const trackColor = '#cccccc';
export const TrackLine = () => {
const { track, passedTrackIndex, currentPosition } = useMapWidgetContext();
const mappedTrack: Point[] = useMemo(
() => (track ? track.map(({ lat, lon }) => [lon, lat]) : []),
[track]
);
return (
<>
<Line coordinates={mappedTrack} strokeWidth={2.5} strokeLinecap="round" stroke={trackColor} />
<Line
coordinates={[
...mappedTrack.slice(0, passedTrackIndex),
getMapPoint(currentPosition ?? zeroCoordinates),
]}
strokeWidth={3.5}
strokeLinecap="round"
stroke={passedTrackColor}
/>
</>
);
};

@ -0,0 +1,8 @@
// TODO: resolve circular deps
import { StationLabelContent, StationLabelContentProps } from './StationLabelContent';
export const StationLabel = ({ station }: StationLabelContentProps) => (
<foreignObject className="track-station" {...station.labelOffset}>
<StationLabelContent station={station} />
</foreignObject>
);

@ -0,0 +1,55 @@
import { HTMLAttributes, ReactNode, useContext } from 'react';
import { StationOnMap, TransportIcon, useMapWidgetContext } from '@mt/components';
import { OnMapTextAlignment } from '@mt/common-types';
import { LocalizationContext } from '@mt/i18n';
export interface StationLabelContentProps extends HTMLAttributes<HTMLDivElement> {
station: StationOnMap;
children?: ReactNode;
}
type TextAlign = Lowercase<OnMapTextAlignment>;
export const StationLabelContent = ({
station,
children,
className = '',
}: StationLabelContentProps) => {
const { locale } = useContext(LocalizationContext);
const { rotateAngle } = useMapWidgetContext();
const { pointOnMap, labelAlignment, iconUrl, shortName, name, transferStationInfos } = station;
return (
<div
id={`${pointOnMap.lat}:${pointOnMap.lon}`}
className={`track-station__wrapper ${className}`}
style={{
textAlign: labelAlignment as TextAlign,
// Inverse angle to compensate map rotation
transform: `rotate(${-rotateAngle}deg)`,
}}
>
<div className="track-station__label">
{iconUrl && <img className="track-station__icon" src={iconUrl} />}
{(shortName ?? name).ru}
</div>
<div className="track-station__transfers-wrapper">
{transferStationInfos.map((transfer) => (
<div className="track-station__label" key={transfer.name.ru}>
<TransportIcon type={transfer.type} className="transport-icon" />
{transfer.name.ru}
</div>
))}
</div>
<div className="track-station__label-locale">
{locale === 'zh' ? (shortName ?? name).zh : (shortName ?? name).en}
</div>
{children}
</div>
);
};

@ -0,0 +1,63 @@
import { useState } from 'react';
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
import { ButtonGroup, IconButton } from '@mui/material';
import AlignHorizontalLeftRoundedIcon from '@mui/icons-material/AlignHorizontalLeftRounded';
import AlignHorizontalCenterRoundedIcon from '@mui/icons-material/AlignHorizontalCenterRounded';
import AlignHorizontalRightRoundedIcon from '@mui/icons-material/AlignHorizontalRightRounded';
// TODO: resolve circular deps
import { OnMapOffset, OnMapTextAlignment } from '@mt/common-types';
import { useMapWidgetContext } from '@mt/components';
import { StationLabelContent, StationLabelContentProps } from './StationLabelContent';
const CONTAINER_WIDTH = 1343;
const SVG_WIDTH = 500;
export const StationLabelEdit = ({ station }: StationLabelContentProps) => {
const { onStationUpdate } = useMapWidgetContext();
const { id, labelOffset } = station;
const [calculatedOffset] = useState<OnMapOffset>(labelOffset);
const [dragStartPoint, setDragStartPoint] = useState<OnMapOffset>({ x: -1, y: -1 });
const onDragStart = () => setDragStartPoint(calculatedOffset);
const onDragStop = (e: DraggableEvent, { lastX, lastY }: DraggableData) => {
const labelOffset = {
x: dragStartPoint.x + lastX,
y: dragStartPoint.y + lastY,
};
onStationUpdate(id, { labelOffset });
};
const setAlignment = (labelAlignment: OnMapTextAlignment): void =>
onStationUpdate(id, { labelAlignment });
return (
<Draggable
onStart={onDragStart}
onStop={onDragStop}
scale={CONTAINER_WIDTH / SVG_WIDTH}
positionOffset={calculatedOffset}
>
<foreignObject className="track-station">
<StationLabelContent station={station} className="editable">
<ButtonGroup size="small" className="align-btns-group">
<IconButton size="small" onClick={() => setAlignment('LEFT')}>
<AlignHorizontalLeftRoundedIcon />
</IconButton>
<IconButton size="small" onClick={() => setAlignment('CENTER')}>
<AlignHorizontalCenterRoundedIcon />
</IconButton>
<IconButton size="small" onClick={() => setAlignment('RIGHT')}>
<AlignHorizontalRightRoundedIcon />
</IconButton>
</ButtonGroup>
</StationLabelContent>
</foreignObject>
</Draggable>
);
};

@ -0,0 +1,68 @@
/* foreignObject */
.track-station {
overflow: visible;
}
.track-station__wrapper {
transform-origin: left top;
transform-box: fill-box;
position: absolute;
top: 0;
left: 0;
}
.track-station__wrapper.editable {
cursor: move;
}
.track-station__label,
.track-station__label-locale {
white-space: nowrap;
}
.track-station__label {
color: #ffffff;
font-size: 0.32rem;
line-height: 1;
letter-spacing: initial;
}
.track-station__label-locale {
color: #cbcbcb;
font-size: 0.25rem;
}
.track-station__transfers-wrapper:not(:empty) {
margin: 1.5px 0;
}
.align-btns-group {
position: absolute;
left: 50%;
transform: translate(-50%, 0);
background-color: #ffffff;
display: none !important;
}
.track-station__wrapper:hover .align-btns-group {
display: flex !important;
}
.align-btns-group > button {
width: 10px;
height: 10px;
}
.transport-icon,
.track-station__icon {
display: inline-block;
margin-right: 1px;
vertical-align: middle;
}
.transport-icon,
.track-station__icon,
.track-station svg {
width: 6px;
height: 6px;
}

@ -0,0 +1,57 @@
import { Marker } from 'react-simple-maps';
// TODO: resolve circular deps
import type { uuid } from '@mt/common-types';
import { getMapPoint } from '../../utils';
import './TrackStations.css';
import { StationLabelEdit } from './StationLabelEdit';
import { useMapWidgetContext } from '../../MapWidgetContext';
import { StationLabel } from './StationLabel';
const colors = {
black: '#000000',
red: '#ed1c24',
grey: '#cccccc',
yellow: '#fcd500',
};
export const TrackStations = () => {
const { stations, currentStation, passedTrackIndex, isOnStation, isEditMode } =
useMapWidgetContext();
const isTerminalStation = (index: number) => index === 0 || index === stations.length - 1;
const getStationFill = (id: uuid, trackIndex: number, index: number): string => {
if (isOnStation && currentStation?.id === id) {
return colors.yellow;
}
if (isTerminalStation(index)) {
return colors.black;
}
return trackIndex <= passedTrackIndex ? colors.red : colors.grey;
};
const getStationStroke = (index: number) => {
if (index === 0) return colors.red;
if (index === stations.length - 1) return colors.grey;
return colors.black;
};
return (
<>
{stations.map((it, index) => (
<Marker key={it.id} coordinates={getMapPoint(it.pointOnMap)}>
<circle
fill={getStationFill(it.id, it.pointOnMap.trackIndex, index)}
stroke={getStationStroke(index)}
r={3.5}
strokeWidth={isTerminalStation(index) ? 2 : 1.5}
/>
{isEditMode ? <StationLabelEdit station={it} /> : <StationLabel station={it} />}
</Marker>
))}
</>
);
};

@ -0,0 +1 @@
export * from './TrackStations';

@ -0,0 +1,36 @@
.icon {
width: 100%;
height: 100%;
}
.iconContainer {
position: relative;
overflow: visible;
border-radius: 50%;
background: #e20613;
padding: 3px;
width: 32px;
height: 32px;
transform: translate(16px, 16px);
}
.flipped {
transform: translate(-48px, -48px);
}
.iconContainer:after {
content: '';
background: linear-gradient(135deg, #e20713, #00000000);
clip-path: polygon(0 0, 50% 100%, 100% 50%);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transform: translate(-50%, -50%);
transform-origin: bottom right;
}
.flipped:after {
transform: translate(-50%, -50%) rotate(180deg);
}

@ -0,0 +1,48 @@
import { useEffect, useState } from 'react';
import { getIntersection, getIntersectionArea } from '../../utils';
import { Marker, Point } from 'react-simple-maps';
import { Coordinates } from '@mt/common-types';
import cn from 'classnames';
import styles from './TramMarker.module.css';
import { Icons, useMapWidgetContext } from '@mt/components';
interface TramMarkerProps {
coordinates: Point;
nextStopPoint: Coordinates;
}
export const TramMarker = ({ coordinates, nextStopPoint }: TramMarkerProps) => {
const [flipped, setFlipped] = useState(false);
const { rotateAngle } = useMapWidgetContext();
useEffect(() => {
const tramRect = document.getElementById('tram-marker')?.getBoundingClientRect();
const nextStopRect = document
.getElementById(`${nextStopPoint.lat}:${nextStopPoint.lon}`)
?.getBoundingClientRect();
if (tramRect && nextStopRect) {
// prettier-ignore
const hasIntersection = getIntersection(tramRect, nextStopRect) !== null;
const intersectionArea = getIntersectionArea(tramRect, nextStopRect);
if (hasIntersection && intersectionArea > 150) {
setFlipped((isFlipped) => !isFlipped);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [coordinates]);
return (
<Marker coordinates={coordinates} id="tram-marker">
<foreignObject className={cn(styles.iconContainer, { [styles.flipped]: flipped })}>
<Icons.TramMarkerIcon
className={`${styles.icon} g-transform-origin__center`}
// Inverse angle to compensate map rotation
style={{ transform: `rotate(${-rotateAngle}deg)` }}
/>
</foreignObject>
</Marker>
);
};

@ -0,0 +1 @@
export * from './TramMarker';

@ -0,0 +1,5 @@
export * from './TramMarker';
export * from './TrackLine';
export * from './TrackStations';
export * from './TrackAttractions';
export * from './MapWidget';

@ -0,0 +1,2 @@
export * from './usePassedTrackIndex';
export * from './useNearStation';

@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import { StationOnMap } from '../map-widget.interface';
import { getDistance } from '../utils';
import { Coordinates } from '@mt/common-types';
const ZOOM_DISTANCE = 100;
const ON_STATION_DISTANCE = 15;
export function useNearStation(
currentPosition: Coordinates | null,
stations: StationOnMap[],
passedTrackIndex: number
) {
const [nextStation, setNextStation] = useState<StationOnMap | null>(null);
const [currentStation, setCurrentStation] = useState<StationOnMap | null>(null);
const [isOnStation, setIsOnStation] = useState<boolean>(false);
useEffect(() => {
if (!currentPosition) {
return;
}
const nextStationIndex = stations.findIndex(
({ pointOnMap }) => pointOnMap.trackIndex > passedTrackIndex
);
const nextStation = stations[nextStationIndex] ?? null;
const prevStation = stations[nextStationIndex - 1] ?? null;
const distanceToNext = nextStation
? getDistance(currentPosition, nextStation.pointOnMap)
: ZOOM_DISTANCE + 1;
setNextStation(nextStation);
if (distanceToNext <= ZOOM_DISTANCE) {
setCurrentStation(nextStation);
setIsOnStation(distanceToNext <= ON_STATION_DISTANCE);
return;
}
const distanceToPrev = prevStation
? getDistance(currentPosition, prevStation.pointOnMap)
: ZOOM_DISTANCE + 1;
if (distanceToPrev <= ZOOM_DISTANCE) {
setCurrentStation(prevStation);
setIsOnStation(distanceToPrev <= ON_STATION_DISTANCE);
return;
}
setCurrentStation(null);
setIsOnStation(false);
}, [currentPosition, stations, passedTrackIndex]);
return { currentStation, nextStation, isOnStation };
}

@ -0,0 +1,56 @@
import { useEffect, useState } from 'react';
import { Coordinates, Track } from '@mt/common-types';
import { getDistance, getPointDeviation } from '../utils';
const APPROXIMATE_DISTANCE = 15; // [meters] half of tramway length (~30 meters)
export function usePassedTrackIndex(track: Track | null, currentPosition: Coordinates | null) {
const [passedTrackIndex, setPassedTrackIndex] = useState<number>(0);
useEffect(() => {
if (!track || !currentPosition) {
return;
}
let minDistance = getDistance(track[0], currentPosition);
let newPassedIndex = 0;
for (let i = 1; i < track.length; i++) {
const distance = getDistance(track[i], currentPosition);
if (distance < minDistance) {
newPassedIndex = i;
minDistance = distance;
}
}
/**
* Is current position more than APPROXIMATE_DISTANCE far from found track point
* we need to check that we really reach newPassedIndex. If not — should decrement index
*/
if (getDistance(track[newPassedIndex], currentPosition) > APPROXIMATE_DISTANCE) {
const prevIndex = Math.max(newPassedIndex - 1, 0);
const nextIndex = Math.min(newPassedIndex + 1, track.length - 1);
const leftDeviation = getPointDeviation(
track[prevIndex],
track[newPassedIndex], // Ближайшая точка трека
currentPosition
);
const rightDeviation = getPointDeviation(
track[newPassedIndex], // Ближайшая точка трека
track[nextIndex],
currentPosition
);
if (leftDeviation >= rightDeviation) {
newPassedIndex--;
}
}
setPassedTrackIndex(newPassedIndex);
}, [track, currentPosition]);
return { passedTrackIndex };
}

@ -0,0 +1,5 @@
export * from './map-widget.constant';
export * from './map-widget.interface';
export * from './components';
export * from './MapWidgetContext';
export * from './map-widget-context.interface';

@ -0,0 +1,49 @@
import { Coordinates, SetState, uuid } from '@mt/common-types';
import { MapData, StationOnMap } from '@mt/components';
import { GeoProjection } from 'd3-geo';
import { Point } from 'react-simple-maps';
import { UseFormReturn } from 'react-hook-form';
import { RouteStation } from '@admin/types';
export interface MapWidgetContextType {
// External data
track: MapData['trackPoints'] | null;
stations: MapData['stationsOnMap'];
attractionGroups: MapData['touristAttractionGroupsOnMap'];
rotateAngle: MapData['mapRotateAngle'];
center: Coordinates;
projection: GeoProjection;
currentPosition: Coordinates | null;
middleTrackCoordinates: Coordinates | null;
passedTrackIndex: number;
currentStation: StationOnMap | null;
nextStation: StationOnMap | null;
isOnStation: boolean;
// Calculated data
setCurrentPosition: SetState<Coordinates | null>;
isDragMode: boolean;
setIsDragMode: SetState<boolean>;
isEditMode: boolean;
setIsEditMode: SetState<boolean>;
onMapDataFetched: (payload: MapData) => void;
settingsForm: UseFormReturn<MapSettings>;
onSettingsFormChange: () => void;
onMapCenterMoved: (center: Coordinates) => void;
onStationUpdate: (
stationId: uuid,
data: Partial<Pick<StationOnMap, 'labelAlignment' | 'labelOffset'>>
) => void;
getUpdatedStations: () => Partial<RouteStation>;
isMapDataChanged: boolean;
}
export interface MapSettings {
rotateAngle: number;
fullScale: number;
zoomedScale: number;
center: Coordinates;
currentStationId?: string;
}

@ -0,0 +1,10 @@
import { MapSettings } from './map-widget-context.interface';
export const zeroCoordinates = { lat: 0, lon: 0 };
export const EMPTY_SETTING_VALUE: MapSettings = {
rotateAngle: 0,
center: zeroCoordinates,
fullScale: 0,
zoomedScale: 0,
};

@ -0,0 +1,38 @@
import {
AttractionGroupIconSizeType,
Coordinates,
StationOnMap as StationOnMapBase,
Track,
uuid,
} from '@mt/common-types';
import { Transfer } from '@front/types';
export type PointOnTrack = Coordinates & {
trackIndex: number;
};
export type StationOnMap = StationOnMapBase & {
pointOnMap: PointOnTrack;
transferStationInfos: Transfer[];
};
export interface AttractionOnMap {
id: uuid;
pointOnMap: Coordinates;
}
export interface AttractionGroup {
iconSize: AttractionGroupIconSizeType;
pointOnMap: Coordinates;
touristAttractionsOnMap: AttractionOnMap[];
}
export interface MapData {
mapRotateAngle: number;
fullMapScale: number;
zoomedMapScale: number;
centerOfMapPoint: Coordinates;
trackPoints: Track;
stationsOnMap: StationOnMap[];
touristAttractionGroupsOnMap: AttractionGroup[];
}

@ -0,0 +1,23 @@
import { Coordinates } from '@mt/common-types';
import { getDistance } from './get-distance';
/**
* This function return deviation of point form the passed straight line
* If deviation equals 0 this means the point lay on the line
* otherwise don't and we can draw a triangle by this 3 point
* @param begin: Point
* @param end: Point
* @param point: Point
* @returns deviation: number
*/
export function getPointDeviation(
begin: Coordinates,
end: Coordinates,
point: Coordinates
): number {
const distanceBtw = getDistance(begin, end);
const distanceTo = getDistance(begin, point);
const distanceFrom = getDistance(point, end);
return distanceBtw - (distanceFrom + distanceTo);
}

@ -0,0 +1,28 @@
import { Coordinates } from '@mt/common-types';
const EARTH_RADIUS = 6372795; // meters
export function getDistance(a: Coordinates, b: Coordinates): number {
const { PI, sin, cos, pow, sqrt, atan2 } = Math;
const aRad = {
lat: (a.lat * PI) / 180,
lon: (a.lon * PI) / 180,
};
const bRad = {
lat: (b.lat * PI) / 180,
lon: (b.lon * PI) / 180,
};
const delta = bRad.lon - aRad.lon;
// вычисления длины большого круга
const y = sqrt(
pow(cos(bRad.lat) * sin(delta), 2) +
pow(cos(aRad.lat) * sin(bRad.lat) - sin(aRad.lat) * cos(bRad.lat) * cos(delta), 2)
);
const x = sin(aRad.lat) * sin(bRad.lat) + cos(aRad.lat) * cos(bRad.lat) * cos(delta);
return +(atan2(y, x) * EARTH_RADIUS).toFixed(2);
}

@ -0,0 +1,6 @@
import { Coordinates } from '@mt/common-types';
import { Point } from 'react-simple-maps';
export function getMapPoint({ lat, lon }: Coordinates): Point {
return [lon, lat];
}

@ -0,0 +1,4 @@
export * from './get-deviation';
export * from './get-distance';
export * from './get-map-point';
export * from './intersections';

@ -0,0 +1,30 @@
interface Rectangle {
left: number;
top: number;
right: number;
bottom: number;
}
export function getIntersection(rect1: Rectangle, rect2: Rectangle): Rectangle | null {
const left = Math.max(rect1.left, rect2.left);
const top = Math.max(rect1.top, rect2.top);
const right = Math.min(rect1.right, rect2.right);
const bottom = Math.min(rect1.bottom, rect2.bottom);
if (left < right && top < bottom) {
return { left, top, right, bottom };
}
return null;
}
export function getIntersectionArea(rect1: Rectangle, rect2: Rectangle): number {
const intersection = getIntersection(rect1, rect2);
if (intersection === null) {
return 0;
}
const width = intersection.right - intersection.left;
const height = intersection.bottom - intersection.top;
return width * height;
}

@ -0,0 +1,67 @@
.root {
display: flex;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%),
rgba(179, 165, 152, 0.4);
box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12);
border-radius: 10px;
}
.number {
width: 96px;
height: 96px;
background: #fcd500;
border-radius: 10px;
display: flex;
justify-content: center;
align-items: center;
font-size: 92px;
font-weight: 900;
}
.content {
width: 265px;
height: 96px;
display: flex;
flex-direction: column;
justify-content: center;
padding: 8px;
}
.title {
white-space: nowrap;
overflow: hidden;
}
.crawlLine {
display: inline-block;
animation: crawl linear infinite;
animation-duration: 10s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.titleStart,
.titleEnd {
font-weight: 700;
font-size: 24px;
line-height: 28px;
color: #ffffff;
}
.titleTranslation {
margin-top: 4px;
font-weight: 400;
font-size: 12px;
line-height: 15px;
color: #cbcbcb;
}
@keyframes crawl {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(-100%);
}
}

@ -0,0 +1,68 @@
import React, { HTMLAttributes, useContext, useEffect, useRef } from 'react';
import { LocalizationContext, LocalizedString } from '@mt/i18n';
import styles from './RouteInfoWidget.module.css';
import cn from 'classnames';
export interface RouteInfoData {
routeNumber: string;
firstStationName: LocalizedString;
lastStationName: LocalizedString;
}
interface RouteInfoWidgetProps extends HTMLAttributes<HTMLDivElement> {
routeInfo?: RouteInfoData;
}
export function RouteInfoWidget({ routeInfo, className, ...props }: RouteInfoWidgetProps) {
const contentContainerRef = useRef<HTMLDivElement>(null);
const titleRefs = useRef<Array<HTMLSpanElement>>([]);
const { locale } = useContext(LocalizationContext);
useEffect(() => {
if (!routeInfo?.firstStationName || !routeInfo?.lastStationName) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const container = contentContainerRef.current!;
const containerWidth = container.offsetWidth;
titleRefs.current.forEach((title) => {
const titleWidth = title.offsetWidth;
const paddingWidth = 8 * 2;
if (titleWidth + paddingWidth > containerWidth) {
title.classList.add(styles.crawlLine);
} else {
title.classList.remove(styles.crawlLine);
}
});
}, [routeInfo, titleRefs, contentContainerRef]);
return (
<div className={cn(styles.root, className)} {...props}>
<div className={styles.number}>{routeInfo?.routeNumber || '--'}</div>
{routeInfo ? (
<div className={styles.content} ref={contentContainerRef}>
<div className={cn(styles.title, styles.titleStart)}>
<span ref={(ref) => (titleRefs.current[0] = ref!)}>
{routeInfo.firstStationName.ru}
</span>
</div>
<div className={cn(styles.title, styles.titleEnd)}>
<span ref={(ref) => (titleRefs.current[1] = ref!)}>{routeInfo.lastStationName.ru}</span>
</div>
<div className={cn(styles.title, styles.titleTranslation)}>
<span ref={(ref) => (titleRefs.current[2] = ref!)}>
{locale === 'zh'
? `${routeInfo.firstStationName.zh} ${routeInfo.lastStationName.zh}`
: `${routeInfo.firstStationName.en} ${routeInfo.lastStationName.en}`}
</span>
</div>
</div>
) : null}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More