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

View File

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

View File

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

View File

@ -1,83 +1,132 @@
import {Autocomplete, Box, TextField, FormControlLabel, Checkbox, 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'
import {
Autocomplete,
Box,
TextField,
FormControlLabel,
Checkbox,
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 = () => {
const {
saveButtonProps,
register,
control,
formState: {errors},
} = useForm({})
formState: { errors },
} = useForm({});
const {id: routeId} = useParams<{id: string}>()
const { id: routeId } = useParams<{ id: string }>();
const {autocompleteProps: carrierAutocompleteProps} = useAutocomplete({
resource: 'carrier',
const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({
resource: "carrier",
onSearch: (value) => [
{
field: 'short_name',
operator: 'contains',
field: "short_name",
operator: "contains",
value,
},
],
})
});
return (
<Edit saveButtonProps={saveButtonProps}>
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
<Box
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<Controller
control={control}
name="carrier_id"
rules={{required: 'Это поле является обязательным'}}
rules={{ required: "Это поле является обязательным" }}
defaultValue={null}
render={({field}) => (
render={({ field }) => (
<Autocomplete
{...carrierAutocompleteProps}
value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null}
value={
carrierAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => {
field.onChange(value?.id || '')
field.onChange(value?.id || "");
}}
getOptionLabel={(item) => {
return item ? item.short_name : ''
return item ? item.short_name : "";
}}
isOptionEqualToValue={(option, value) => {
return option.id === value?.id
return option.id === value?.id;
}}
filterOptions={(options, {inputValue}) => {
return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase()))
filterOptions={(options, { inputValue }) => {
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
{...register('route_number', {
required: 'Это поле является обязательным',
{...register("route_number", {
required: "Это поле является обязательным",
setValueAs: (value) => String(value),
})}
error={!!(errors as any)?.route_number}
helperText={(errors as any)?.route_number?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Номер маршрута'}
label={"Номер маршрута"}
name="route_number"
/>
<Controller
name="route_direction" // boolean
control={control}
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>
@ -86,38 +135,68 @@ export const RouteEdit = () => {
control={control}
defaultValue={[]}
rules={{
required: 'Это поле является обязательным',
required: "Это поле является обязательным",
validate: (value: unknown) => {
if (!Array.isArray(value)) return 'Неверный формат'
if (!value.every((point: unknown) => Array.isArray(point) && point.length === 2)) {
return 'Каждая точка должна быть массивом из двух координат'
if (!Array.isArray(value)) return "Неверный формат";
if (value.length === 0)
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'))) {
return 'Координаты должны быть числами'
if (
!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
{...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) => {
try {
const parsed = JSON.parse(e.target.value)
field.onChange(parsed)
const lines = e.target.value.trim().split("\n");
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 {
field.onChange([])
field.onChange([]);
}
}}
error={!!error}
helperText={error?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]'
helperText={error?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Координаты маршрута'}
placeholder="[[1.1, 2.2], [2.1, 4.5]]"
label={"Координаты маршрута *"}
placeholder="55.7558 37.6173
55.7539 37.6208"
multiline
rows={4}
sx={{
marginBottom: 2,
}}
@ -126,111 +205,130 @@ export const RouteEdit = () => {
/>
<TextField
{...register('route_sys_number', {
required: 'Это поле является обязательным',
{...register("route_sys_number", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.route_sys_number}
helperText={(errors as any)?.route_sys_number?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Системный номер маршрута *'}
label={"Системный номер маршрута *"}
name="route_sys_number"
/>
<TextField
{...register('governor_appeal', {
{...register("governor_appeal", {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.governor_appeal}
helperText={(errors as any)?.governor_appeal?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Обращение губернатора'}
label={"Обращение губернатора"}
name="governor_appeal"
/>
<TextField
{...register('scale_min', {
{...register("scale_min", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.scale_min}
helperText={(errors as any)?.scale_min?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Масштаб (мин)'}
label={"Масштаб (мин)"}
name="scale_min"
/>
<TextField
{...register('scale_max', {
{...register("scale_max", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.scale_max}
helperText={(errors as any)?.scale_max?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Масштаб (макс)'}
label={"Масштаб (макс)"}
name="scale_max"
/>
<TextField
{...register('rotate', {
{...register("rotate", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.rotate}
helperText={(errors as any)?.rotate?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Поворот'}
label={"Поворот"}
name="rotate"
/>
<TextField
{...register('center_latitude', {
{...register("center_latitude", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.center_latitude}
helperText={(errors as any)?.center_latitude?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Центр. широта'}
label={"Центр. широта"}
name="center_latitude"
/>
<TextField
{...register('center_longitude', {
{...register("center_longitude", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.center_longitude}
helperText={(errors as any)?.center_longitude?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Центр. долгота'}
label={"Центр. долгота"}
name="center_longitude"
/>
</Box>
{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>
)
}
);
};

View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 MiB

View File

@ -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 />;
};

View File

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

View File

@ -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,
},
};
});
}

View File

@ -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,
}
);
}

View File

@ -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} />;
};

View File

@ -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;
};

View File

@ -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} />;
}

View File

@ -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 };
};

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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>
)
);
}

View File

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

View File

@ -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>
);
}

View File

@ -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 />
</>
);
}

View File

@ -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 };
};

View File

@ -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 };
};

View File

@ -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>
);
};

View File

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

View File

@ -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} />;
}

View File

@ -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>
);
};

View File

@ -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)}`;
};
}

View File

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

View File

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

View File

@ -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 };
});
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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} />;
}

View File

@ -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;
}

View File

@ -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%;
}
`;

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
});
}

View File

@ -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;
};

View File

@ -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;
}

View File

@ -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
View 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"
}

View File

@ -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>;

View File

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

View File

@ -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
}

View File

@ -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
View File

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

View File

@ -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
View File

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

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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
);
}
);

View File

@ -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" />
)}
</>
);

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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" />
)}
</>
);

View File

@ -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);
}
`;

View File

@ -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>
);
}

View File

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

View File

@ -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;
};

View File

@ -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>
);
};

View File

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

View File

@ -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>
);
};

View File

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

View File

@ -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;
}

View File

@ -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>
);
};

View File

@ -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}
/>
))}
</>
);
};

View File

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

View File

@ -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}
/>
</>
);
};

View File

@ -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>
);

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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;
}

View File

@ -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>
))}
</>
);
};

View File

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

View File

@ -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);
}

View File

@ -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>
);
};

View File

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

View File

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

View File

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

View File

@ -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 };
}

View File

@ -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 };
}

View File

@ -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';

View File

@ -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;
}

View File

@ -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,
};

View File

@ -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[];
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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];
}

View File

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

View File

@ -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;
}

View File

@ -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%);
}
}

View File

@ -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