Latest version #12
| @@ -1,14 +1,14 @@ | |||||||
| import React from 'react' | import React from "react"; | ||||||
| import {createRoot} from 'react-dom/client' | import { createRoot } from "react-dom/client"; | ||||||
|  |  | ||||||
| import App from './App' | import App from "./App"; | ||||||
| import './globals.css' | import "./globals.css"; | ||||||
|  |  | ||||||
| const container = document.getElementById('root') as HTMLElement | const container = document.getElementById("root") as HTMLElement; | ||||||
| const root = createRoot(container) | const root = createRoot(container); | ||||||
|  |  | ||||||
| root.render( | root.render( | ||||||
|   <React.StrictMode> |   <React.StrictMode> | ||||||
|     <App /> |     <App /> | ||||||
|   </React.StrictMode>, |   </React.StrictMode> | ||||||
| ) | ); | ||||||
|   | |||||||
| @@ -130,20 +130,32 @@ export const RouteCreate = () => { | |||||||
|             required: "Это поле является обязательным", |             required: "Это поле является обязательным", | ||||||
|             setValueAs: (value: string) => { |             setValueAs: (value: string) => { | ||||||
|               try { |               try { | ||||||
|                 // Парсим строку в массив массивов |                 // Разбиваем строку на строки и парсим каждую строку как пару координат | ||||||
|                 return JSON.parse(value); |                 const lines = value.trim().split("\n"); | ||||||
|  |                 return lines.map((line) => { | ||||||
|  |                   const [lat, lon] = line | ||||||
|  |                     .trim() | ||||||
|  |                     .split(/[\s,]+/) | ||||||
|  |                     .map(Number); | ||||||
|  |                   if (isNaN(lat) || isNaN(lon)) { | ||||||
|  |                     throw new Error("Invalid coordinates"); | ||||||
|  |                   } | ||||||
|  |                   return [lat, lon]; | ||||||
|  |                 }); | ||||||
|               } catch { |               } catch { | ||||||
|                 return []; |                 return []; | ||||||
|               } |               } | ||||||
|             }, |             }, | ||||||
|             validate: (value: unknown) => { |             validate: (value: unknown) => { | ||||||
|               if (!Array.isArray(value)) return "Неверный формат"; |               if (!Array.isArray(value)) return "Неверный формат"; | ||||||
|  |               if (value.length === 0) | ||||||
|  |                 return "Введите хотя бы одну пару координат"; | ||||||
|               if ( |               if ( | ||||||
|                 !value.every( |                 !value.every( | ||||||
|                   (point: unknown) => Array.isArray(point) && point.length === 2 |                   (point: unknown) => Array.isArray(point) && point.length === 2 | ||||||
|                 ) |                 ) | ||||||
|               ) { |               ) { | ||||||
|                 return "Каждая точка должна быть массивом из двух координат"; |                 return "Каждая строка должна содержать две координаты"; | ||||||
|               } |               } | ||||||
|               if ( |               if ( | ||||||
|                 !value.every((point: unknown[]) => |                 !value.every((point: unknown[]) => | ||||||
| @@ -159,14 +171,17 @@ export const RouteCreate = () => { | |||||||
|             }, |             }, | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.path} |           error={!!(errors as any)?.path} | ||||||
|           helperText={(errors as any)?.path?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]' |           helperText={(errors as any)?.path?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{ shrink: true }} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="text" |           type="text" | ||||||
|           label={"Координаты маршрута *"} |           label={"Координаты маршрута *"} | ||||||
|           name="path" |           name="path" | ||||||
|           placeholder="[[1.1, 2.2], [2.1, 4.5]]" |           placeholder="55.7558 37.6173 | ||||||
|  | 55.7539 37.6208" | ||||||
|  |           multiline | ||||||
|  |           rows={4} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
| @@ -186,6 +201,7 @@ export const RouteCreate = () => { | |||||||
|         <TextField |         <TextField | ||||||
|           {...register("governor_appeal", { |           {...register("governor_appeal", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|  |             setValueAs: (value) => Number(value), | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.governor_appeal} |           error={!!(errors as any)?.governor_appeal} | ||||||
|           helperText={(errors as any)?.governor_appeal?.message} |           helperText={(errors as any)?.governor_appeal?.message} | ||||||
| @@ -200,6 +216,7 @@ export const RouteCreate = () => { | |||||||
|         <TextField |         <TextField | ||||||
|           {...register("scale_min", { |           {...register("scale_min", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|  |             setValueAs: (value) => Number(value), | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.scale_min} |           error={!!(errors as any)?.scale_min} | ||||||
|           helperText={(errors as any)?.scale_min?.message} |           helperText={(errors as any)?.scale_min?.message} | ||||||
| @@ -214,6 +231,7 @@ export const RouteCreate = () => { | |||||||
|         <TextField |         <TextField | ||||||
|           {...register("scale_max", { |           {...register("scale_max", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|  |             setValueAs: (value) => Number(value), | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.scale_max} |           error={!!(errors as any)?.scale_max} | ||||||
|           helperText={(errors as any)?.scale_max?.message} |           helperText={(errors as any)?.scale_max?.message} | ||||||
| @@ -228,6 +246,7 @@ export const RouteCreate = () => { | |||||||
|         <TextField |         <TextField | ||||||
|           {...register("rotate", { |           {...register("rotate", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|  |             setValueAs: (value) => Number(value), | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.rotate} |           error={!!(errors as any)?.rotate} | ||||||
|           helperText={(errors as any)?.rotate?.message} |           helperText={(errors as any)?.rotate?.message} | ||||||
| @@ -242,6 +261,7 @@ export const RouteCreate = () => { | |||||||
|         <TextField |         <TextField | ||||||
|           {...register("center_latitude", { |           {...register("center_latitude", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|  |             setValueAs: (value) => Number(value), | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.center_latitude} |           error={!!(errors as any)?.center_latitude} | ||||||
|           helperText={(errors as any)?.center_latitude?.message} |           helperText={(errors as any)?.center_latitude?.message} | ||||||
| @@ -256,6 +276,7 @@ export const RouteCreate = () => { | |||||||
|         <TextField |         <TextField | ||||||
|           {...register("center_longitude", { |           {...register("center_longitude", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|  |             setValueAs: (value) => Number(value), | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.center_longitude} |           error={!!(errors as any)?.center_longitude} | ||||||
|           helperText={(errors as any)?.center_longitude?.message} |           helperText={(errors as any)?.center_longitude?.message} | ||||||
|   | |||||||
| @@ -1,83 +1,132 @@ | |||||||
| import {Autocomplete, Box, TextField, FormControlLabel, Checkbox, Typography} from '@mui/material' | import { | ||||||
| import {Edit, useAutocomplete} from '@refinedev/mui' |   Autocomplete, | ||||||
| import {useForm} from '@refinedev/react-hook-form' |   Box, | ||||||
| import {Controller} from 'react-hook-form' |   TextField, | ||||||
| import {useParams} from 'react-router' |   FormControlLabel, | ||||||
| import {LinkedItems} from '../../components/LinkedItems' |   Checkbox, | ||||||
| import {StationItem, VehicleItem, stationFields, vehicleFields} from './types' |   Typography, | ||||||
|  | } from "@mui/material"; | ||||||
|  | import { Edit, useAutocomplete } from "@refinedev/mui"; | ||||||
|  | import { useForm } from "@refinedev/react-hook-form"; | ||||||
|  | import { Controller } from "react-hook-form"; | ||||||
|  | import { useParams } from "react-router"; | ||||||
|  | import { LinkedItems } from "../../components/LinkedItems"; | ||||||
|  | import { | ||||||
|  |   StationItem, | ||||||
|  |   VehicleItem, | ||||||
|  |   stationFields, | ||||||
|  |   vehicleFields, | ||||||
|  | } from "./types"; | ||||||
|  |  | ||||||
| export const RouteEdit = () => { | export const RouteEdit = () => { | ||||||
|   const { |   const { | ||||||
|     saveButtonProps, |     saveButtonProps, | ||||||
|     register, |     register, | ||||||
|     control, |     control, | ||||||
|     formState: {errors}, |     formState: { errors }, | ||||||
|   } = useForm({}) |   } = useForm({}); | ||||||
|  |  | ||||||
|   const {id: routeId} = useParams<{id: string}>() |   const { id: routeId } = useParams<{ id: string }>(); | ||||||
|  |  | ||||||
|   const {autocompleteProps: carrierAutocompleteProps} = useAutocomplete({ |   const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({ | ||||||
|     resource: 'carrier', |     resource: "carrier", | ||||||
|     onSearch: (value) => [ |     onSearch: (value) => [ | ||||||
|       { |       { | ||||||
|         field: 'short_name', |         field: "short_name", | ||||||
|         operator: 'contains', |         operator: "contains", | ||||||
|         value, |         value, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|   }) |   }); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Edit saveButtonProps={saveButtonProps}> |     <Edit saveButtonProps={saveButtonProps}> | ||||||
|       <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> |       <Box | ||||||
|  |         component="form" | ||||||
|  |         sx={{ display: "flex", flexDirection: "column" }} | ||||||
|  |         autoComplete="off" | ||||||
|  |       > | ||||||
|         <Controller |         <Controller | ||||||
|           control={control} |           control={control} | ||||||
|           name="carrier_id" |           name="carrier_id" | ||||||
|           rules={{required: 'Это поле является обязательным'}} |           rules={{ required: "Это поле является обязательным" }} | ||||||
|           defaultValue={null} |           defaultValue={null} | ||||||
|           render={({field}) => ( |           render={({ field }) => ( | ||||||
|             <Autocomplete |             <Autocomplete | ||||||
|               {...carrierAutocompleteProps} |               {...carrierAutocompleteProps} | ||||||
|               value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null} |               value={ | ||||||
|  |                 carrierAutocompleteProps.options.find( | ||||||
|  |                   (option) => option.id === field.value | ||||||
|  |                 ) || null | ||||||
|  |               } | ||||||
|               onChange={(_, value) => { |               onChange={(_, value) => { | ||||||
|                 field.onChange(value?.id || '') |                 field.onChange(value?.id || ""); | ||||||
|               }} |               }} | ||||||
|               getOptionLabel={(item) => { |               getOptionLabel={(item) => { | ||||||
|                 return item ? item.short_name : '' |                 return item ? item.short_name : ""; | ||||||
|               }} |               }} | ||||||
|               isOptionEqualToValue={(option, value) => { |               isOptionEqualToValue={(option, value) => { | ||||||
|                 return option.id === value?.id |                 return option.id === value?.id; | ||||||
|               }} |               }} | ||||||
|               filterOptions={(options, {inputValue}) => { |               filterOptions={(options, { inputValue }) => { | ||||||
|                 return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase())) |                 return options.filter((option) => | ||||||
|  |                   option.short_name | ||||||
|  |                     .toLowerCase() | ||||||
|  |                     .includes(inputValue.toLowerCase()) | ||||||
|  |                 ); | ||||||
|               }} |               }} | ||||||
|               renderInput={(params) => <TextField {...params} label="Выберите перевозчика" margin="normal" variant="outlined" error={!!errors.carrier_id} helperText={(errors as any)?.carrier_id?.message} required />} |               renderInput={(params) => ( | ||||||
|  |                 <TextField | ||||||
|  |                   {...params} | ||||||
|  |                   label="Выберите перевозчика" | ||||||
|  |                   margin="normal" | ||||||
|  |                   variant="outlined" | ||||||
|  |                   error={!!errors.carrier_id} | ||||||
|  |                   helperText={(errors as any)?.carrier_id?.message} | ||||||
|  |                   required | ||||||
|  |                 /> | ||||||
|  |               )} | ||||||
|             /> |             /> | ||||||
|           )} |           )} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('route_number', { |           {...register("route_number", { | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|             setValueAs: (value) => String(value), |             setValueAs: (value) => String(value), | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.route_number} |           error={!!(errors as any)?.route_number} | ||||||
|           helperText={(errors as any)?.route_number?.message} |           helperText={(errors as any)?.route_number?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="text" |           type="text" | ||||||
|           label={'Номер маршрута'} |           label={"Номер маршрута"} | ||||||
|           name="route_number" |           name="route_number" | ||||||
|         /> |         /> | ||||||
|         <Controller |         <Controller | ||||||
|           name="route_direction" // boolean |           name="route_direction" // boolean | ||||||
|           control={control} |           control={control} | ||||||
|           defaultValue={false} |           defaultValue={false} | ||||||
|           render={({field}: {field: any}) => <FormControlLabel label="Прямой маршрут?" control={<Checkbox {...field} checked={field.value} onChange={(e) => field.onChange(e.target.checked)} />} />} |           render={({ field }: { field: any }) => ( | ||||||
|  |             <FormControlLabel | ||||||
|  |               label="Прямой маршрут?" | ||||||
|  |               control={ | ||||||
|  |                 <Checkbox | ||||||
|  |                   {...field} | ||||||
|  |                   checked={field.value} | ||||||
|  |                   onChange={(e) => field.onChange(e.target.checked)} | ||||||
|  |                 /> | ||||||
|  |               } | ||||||
|  |             /> | ||||||
|  |           )} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <Typography variant="caption" color="textSecondary" sx={{mt: 0, mb: 1}}> |         <Typography | ||||||
|  |           variant="caption" | ||||||
|  |           color="textSecondary" | ||||||
|  |           sx={{ mt: 0, mb: 1 }} | ||||||
|  |         > | ||||||
|           (Прямой / Обратный) |           (Прямой / Обратный) | ||||||
|         </Typography> |         </Typography> | ||||||
|  |  | ||||||
| @@ -86,38 +135,68 @@ export const RouteEdit = () => { | |||||||
|           control={control} |           control={control} | ||||||
|           defaultValue={[]} |           defaultValue={[]} | ||||||
|           rules={{ |           rules={{ | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|             validate: (value: unknown) => { |             validate: (value: unknown) => { | ||||||
|               if (!Array.isArray(value)) return 'Неверный формат' |               if (!Array.isArray(value)) return "Неверный формат"; | ||||||
|               if (!value.every((point: unknown) => Array.isArray(point) && point.length === 2)) { |               if (value.length === 0) | ||||||
|                 return 'Каждая точка должна быть массивом из двух координат' |                 return "Введите хотя бы одну пару координат"; | ||||||
|  |               if ( | ||||||
|  |                 !value.every( | ||||||
|  |                   (point: unknown) => Array.isArray(point) && point.length === 2 | ||||||
|  |                 ) | ||||||
|  |               ) { | ||||||
|  |                 return "Каждая строка должна содержать две координаты"; | ||||||
|               } |               } | ||||||
|               if (!value.every((point: unknown[]) => point.every((coord: unknown) => !isNaN(Number(coord)) && typeof coord === 'number'))) { |               if ( | ||||||
|                 return 'Координаты должны быть числами' |                 !value.every((point: unknown[]) => | ||||||
|  |                   point.every( | ||||||
|  |                     (coord: unknown) => | ||||||
|  |                       !isNaN(Number(coord)) && typeof coord === "number" | ||||||
|  |                   ) | ||||||
|  |                 ) | ||||||
|  |               ) { | ||||||
|  |                 return "Координаты должны быть числами"; | ||||||
|               } |               } | ||||||
|               return true |               return true; | ||||||
|             }, |             }, | ||||||
|           }} |           }} | ||||||
|           render={({field, fieldState: {error}}) => ( |           render={({ field, fieldState: { error } }) => ( | ||||||
|             <TextField |             <TextField | ||||||
|               {...field} |               {...field} | ||||||
|               value={Array.isArray(field.value) ? JSON.stringify(field.value) : ''} |               value={ | ||||||
|  |                 Array.isArray(field.value) | ||||||
|  |                   ? field.value.map((point) => point.join(" ")).join("\n") | ||||||
|  |                   : "" | ||||||
|  |               } | ||||||
|               onChange={(e) => { |               onChange={(e) => { | ||||||
|                 try { |                 try { | ||||||
|                   const parsed = JSON.parse(e.target.value) |                   const lines = e.target.value.trim().split("\n"); | ||||||
|                   field.onChange(parsed) |                   const parsed = lines.map((line) => { | ||||||
|  |                     const [lat, lon] = line | ||||||
|  |                       .trim() | ||||||
|  |                       .split(/[\s,]+/) | ||||||
|  |                       .map(Number); | ||||||
|  |                     if (isNaN(lat) || isNaN(lon)) { | ||||||
|  |                       throw new Error("Invalid coordinates"); | ||||||
|  |                     } | ||||||
|  |                     return [lat, lon]; | ||||||
|  |                   }); | ||||||
|  |                   field.onChange(parsed); | ||||||
|                 } catch { |                 } catch { | ||||||
|                   field.onChange([]) |                   field.onChange([]); | ||||||
|                 } |                 } | ||||||
|               }} |               }} | ||||||
|               error={!!error} |               error={!!error} | ||||||
|               helperText={error?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]' |               helperText={error?.message} | ||||||
|               margin="normal" |               margin="normal" | ||||||
|               fullWidth |               fullWidth | ||||||
|               InputLabelProps={{shrink: true}} |               InputLabelProps={{ shrink: true }} | ||||||
|               type="text" |               type="text" | ||||||
|               label={'Координаты маршрута'} |               label={"Координаты маршрута *"} | ||||||
|               placeholder="[[1.1, 2.2], [2.1, 4.5]]" |               placeholder="55.7558 37.6173 | ||||||
|  | 55.7539 37.6208" | ||||||
|  |               multiline | ||||||
|  |               rows={4} | ||||||
|               sx={{ |               sx={{ | ||||||
|                 marginBottom: 2, |                 marginBottom: 2, | ||||||
|               }} |               }} | ||||||
| @@ -126,111 +205,130 @@ export const RouteEdit = () => { | |||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('route_sys_number', { |           {...register("route_sys_number", { | ||||||
|             required: 'Это поле является обязательным', |             required: "Это поле является обязательным", | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.route_sys_number} |           error={!!(errors as any)?.route_sys_number} | ||||||
|           helperText={(errors as any)?.route_sys_number?.message} |           helperText={(errors as any)?.route_sys_number?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="number" |           type="number" | ||||||
|           label={'Системный номер маршрута *'} |           label={"Системный номер маршрута *"} | ||||||
|           name="route_sys_number" |           name="route_sys_number" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('governor_appeal', { |           {...register("governor_appeal", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.governor_appeal} |           error={!!(errors as any)?.governor_appeal} | ||||||
|           helperText={(errors as any)?.governor_appeal?.message} |           helperText={(errors as any)?.governor_appeal?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="number" |           type="number" | ||||||
|           label={'Обращение губернатора'} |           label={"Обращение губернатора"} | ||||||
|           name="governor_appeal" |           name="governor_appeal" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('scale_min', { |           {...register("scale_min", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|  |             setValueAs: (value) => Number(value), | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.scale_min} |           error={!!(errors as any)?.scale_min} | ||||||
|           helperText={(errors as any)?.scale_min?.message} |           helperText={(errors as any)?.scale_min?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="number" |           type="number" | ||||||
|           label={'Масштаб (мин)'} |           label={"Масштаб (мин)"} | ||||||
|           name="scale_min" |           name="scale_min" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('scale_max', { |           {...register("scale_max", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|  |             setValueAs: (value) => Number(value), | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.scale_max} |           error={!!(errors as any)?.scale_max} | ||||||
|           helperText={(errors as any)?.scale_max?.message} |           helperText={(errors as any)?.scale_max?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="number" |           type="number" | ||||||
|           label={'Масштаб (макс)'} |           label={"Масштаб (макс)"} | ||||||
|           name="scale_max" |           name="scale_max" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('rotate', { |           {...register("rotate", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|  |             setValueAs: (value) => Number(value), | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.rotate} |           error={!!(errors as any)?.rotate} | ||||||
|           helperText={(errors as any)?.rotate?.message} |           helperText={(errors as any)?.rotate?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="number" |           type="number" | ||||||
|           label={'Поворот'} |           label={"Поворот"} | ||||||
|           name="rotate" |           name="rotate" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('center_latitude', { |           {...register("center_latitude", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|  |             setValueAs: (value) => Number(value), | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.center_latitude} |           error={!!(errors as any)?.center_latitude} | ||||||
|           helperText={(errors as any)?.center_latitude?.message} |           helperText={(errors as any)?.center_latitude?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="number" |           type="number" | ||||||
|           label={'Центр. широта'} |           label={"Центр. широта"} | ||||||
|           name="center_latitude" |           name="center_latitude" | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <TextField |         <TextField | ||||||
|           {...register('center_longitude', { |           {...register("center_longitude", { | ||||||
|             // required: 'Это поле является обязательным', |             // required: 'Это поле является обязательным', | ||||||
|  |             setValueAs: (value) => Number(value), | ||||||
|           })} |           })} | ||||||
|           error={!!(errors as any)?.center_longitude} |           error={!!(errors as any)?.center_longitude} | ||||||
|           helperText={(errors as any)?.center_longitude?.message} |           helperText={(errors as any)?.center_longitude?.message} | ||||||
|           margin="normal" |           margin="normal" | ||||||
|           fullWidth |           fullWidth | ||||||
|           InputLabelProps={{shrink: true}} |           InputLabelProps={{ shrink: true }} | ||||||
|           type="number" |           type="number" | ||||||
|           label={'Центр. долгота'} |           label={"Центр. долгота"} | ||||||
|           name="center_longitude" |           name="center_longitude" | ||||||
|         /> |         /> | ||||||
|       </Box> |       </Box> | ||||||
|  |  | ||||||
|       {routeId && ( |       {routeId && ( | ||||||
|         <> |         <> | ||||||
|           <LinkedItems<StationItem> type="edit" parentId={routeId} parentResource="route" childResource="station" fields={stationFields} title="станции" /> |           <LinkedItems<StationItem> | ||||||
|  |             type="edit" | ||||||
|  |             parentId={routeId} | ||||||
|  |             parentResource="route" | ||||||
|  |             childResource="station" | ||||||
|  |             fields={stationFields} | ||||||
|  |             title="станции" | ||||||
|  |           /> | ||||||
|  |  | ||||||
|           <LinkedItems<VehicleItem> type="edit" parentId={routeId} parentResource="route" childResource="vehicle" fields={vehicleFields} title="транспортные средства" /> |           <LinkedItems<VehicleItem> | ||||||
|  |             type="edit" | ||||||
|  |             parentId={routeId} | ||||||
|  |             parentResource="route" | ||||||
|  |             childResource="vehicle" | ||||||
|  |             fields={vehicleFields} | ||||||
|  |             title="транспортные средства" | ||||||
|  |           /> | ||||||
|         </> |         </> | ||||||
|       )} |       )} | ||||||
|     </Edit> |     </Edit> | ||||||
|   ) |   ); | ||||||
| } | }; | ||||||
|   | |||||||
							
								
								
									
										0
									
								
								src/preview /assets/.gitkeep
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/preview /assets/.gitkeep
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										7
									
								
								src/preview /assets/icons/company-logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/preview /assets/icons/company-logo.svg
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| After Width: | Height: | Size: 18 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/preview /assets/images/loader.gif
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/preview /assets/images/loader.gif
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 8.3 MiB | 
| @@ -0,0 +1,49 @@ | |||||||
|  | import { MapWidget, useMapWidgetContext } from '@mt/components'; | ||||||
|  | import { useGetMapData } from './useGetMapData'; | ||||||
|  | import { EventQueryData, useEventQuery, useLoading } from '@mt/utils'; | ||||||
|  | import { useEffect } from 'react'; | ||||||
|  | // TODO: resolve circular deps | ||||||
|  | import { Coordinates } from '@mt/common-types'; | ||||||
|  |  | ||||||
|  | export const MapWidgetContainer = () => { | ||||||
|  |   const { isLoading } = useLoading(); | ||||||
|  |   const { data, refetch } = useGetMapData(); | ||||||
|  |   const { currentPosition, setCurrentPosition, onMapDataFetched } = useMapWidgetContext(); | ||||||
|  |  | ||||||
|  |   const { data: events = [], isSuccess } = useEventQuery('/widgets/route-map/events', [ | ||||||
|  |     'REFRESH_DATA', | ||||||
|  |     'UPDATE_CURRENT_POINT_ON_TRACK', | ||||||
|  |   ]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (!data) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     onMapDataFetched(data); | ||||||
|  |   }, [data]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (!isSuccess || !events.length) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (events.some((e) => e['@type'] === 'REFRESH_DATA')) { | ||||||
|  |       refetch(); | ||||||
|  |  | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const { currentPointOnTrack } = events.at(-1) as EventQueryData<{ | ||||||
|  |       currentPointOnTrack: Coordinates; | ||||||
|  |     }>; | ||||||
|  |  | ||||||
|  |     setCurrentPosition(currentPointOnTrack); | ||||||
|  |   }, [events, isSuccess, refetch]); | ||||||
|  |  | ||||||
|  |   if (isLoading || !data || !currentPosition) { | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return <MapWidget />; | ||||||
|  | }; | ||||||
							
								
								
									
										1
									
								
								src/preview /components/MapWidgetContainer/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/preview /components/MapWidgetContainer/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | export * from './MapWidgetContainer'; | ||||||
| @@ -0,0 +1,19 @@ | |||||||
|  | import { Track } from '@mt/common-types'; | ||||||
|  | import { StationOnMap } from '@mt/components'; | ||||||
|  |  | ||||||
|  | export function mapStationsFromApi(stations: StationOnMap[], track: Track): StationOnMap[] { | ||||||
|  |   return stations.map<StationOnMap>((station) => { | ||||||
|  |     const { pointOnMap } = station; | ||||||
|  |     const trackIndex = track.findIndex( | ||||||
|  |       (trackPoint) => pointOnMap.lat === trackPoint.lat && pointOnMap.lon === trackPoint.lon | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |       ...station, | ||||||
|  |       pointOnMap: { | ||||||
|  |         ...pointOnMap, | ||||||
|  |         trackIndex, | ||||||
|  |       }, | ||||||
|  |     }; | ||||||
|  |   }); | ||||||
|  | } | ||||||
							
								
								
									
										23
									
								
								src/preview /components/MapWidgetContainer/useGetMapData.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/preview /components/MapWidgetContainer/useGetMapData.ts
									
									
									
									
									
										Normal 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, | ||||||
|  |     } | ||||||
|  |   ); | ||||||
|  | } | ||||||
| @@ -0,0 +1,9 @@ | |||||||
|  | import { RouteInfoWidget } from '@mt/components'; | ||||||
|  | import { useGetRouteInfo } from './useGetRouteInfo'; | ||||||
|  | import { HTMLAttributes } from 'react'; | ||||||
|  |  | ||||||
|  | export const RouteInfoWidgetContainer = (props: HTMLAttributes<HTMLDivElement>) => { | ||||||
|  |   const { data } = useGetRouteInfo(); | ||||||
|  |  | ||||||
|  |   return <RouteInfoWidget routeInfo={data} {...props} />; | ||||||
|  | }; | ||||||
| @@ -0,0 +1,26 @@ | |||||||
|  | import { useQuery } from 'react-query'; | ||||||
|  | import { useEffect } from 'react'; | ||||||
|  | import { useEventQuery } from '@mt/utils'; | ||||||
|  | import { RouteInfoData } from '@mt/components'; | ||||||
|  |  | ||||||
|  | export const useGetRouteInfo = () => { | ||||||
|  |   const { data, isSuccess } = useEventQuery( | ||||||
|  |     // 'getRouteInfoEvents', | ||||||
|  |     '/widgets/route-info/events', | ||||||
|  |     ['REFRESH_DATA'] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const routeInfoQuery = useQuery<RouteInfoData>( | ||||||
|  |     'getRouteInfo', | ||||||
|  |     async () => | ||||||
|  |       await fetch('https://localhost:8443/widgets/route-info/data').then((res) => res.json()) | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (isSuccess && data.length) { | ||||||
|  |       routeInfoQuery.refetch(); | ||||||
|  |     } | ||||||
|  |   }, [data, isSuccess]); | ||||||
|  |  | ||||||
|  |   return routeInfoQuery; | ||||||
|  | }; | ||||||
| @@ -0,0 +1,9 @@ | |||||||
|  | import { WeatherWidget } from '@mt/components'; | ||||||
|  | import { HTMLAttributes } from 'react'; | ||||||
|  | import { useWeatherData } from './useWeatherData'; | ||||||
|  |  | ||||||
|  | export function WeatherWidgetContainer(props: HTMLAttributes<HTMLDivElement>) { | ||||||
|  |   const { data } = useWeatherData(); | ||||||
|  |  | ||||||
|  |   return <WeatherWidget weatherData={data} {...props} />; | ||||||
|  | } | ||||||
							
								
								
									
										33
									
								
								src/preview /components/WeatherWidget/useWeatherData.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/preview /components/WeatherWidget/useWeatherData.ts
									
									
									
									
									
										Normal 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 }; | ||||||
|  | }; | ||||||
| @@ -0,0 +1,160 @@ | |||||||
|  | .dropdown { | ||||||
|  |   position: relative; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .dropdown-button { | ||||||
|  |   position: relative; | ||||||
|  |   display: flex; | ||||||
|  |   width: 100%; | ||||||
|  |   padding: 18px 0; | ||||||
|  |   justify-content: center; | ||||||
|  |   color: white; | ||||||
|  |   font-weight: 700; | ||||||
|  |   font-size: 18px; | ||||||
|  |   line-height: 21px; | ||||||
|  |   border: none; | ||||||
|  |   background: linear-gradient( | ||||||
|  |       113.51deg, | ||||||
|  |       rgba(255, 255, 255, 0) 8.71%, | ||||||
|  |       rgba(255, 255, 255, 0.16) 69.69% | ||||||
|  |     ), | ||||||
|  |     #806c59; | ||||||
|  |   border-radius: 10px 10px 0 0; | ||||||
|  |   margin-top: 33px; | ||||||
|  |   cursor: pointer; | ||||||
|  |   align-items: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .dropdown-icon { | ||||||
|  |   height: 15px; | ||||||
|  |   width: 21px; | ||||||
|  |   left: 40px; | ||||||
|  |   position: absolute; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .dropdown-icon--opened { | ||||||
|  |   transform: rotate(180deg); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .dropdown-button:hover { | ||||||
|  |   background: #806c59; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .dropdown-content { | ||||||
|  |   overflow-y: auto; | ||||||
|  |   position: absolute; | ||||||
|  |   bottom: 0; | ||||||
|  |   left: 0; | ||||||
|  |   width: 100%; | ||||||
|  |   height: 675px; | ||||||
|  |   padding: 0; | ||||||
|  |   background: linear-gradient( | ||||||
|  |       113.51deg, | ||||||
|  |       rgba(255, 255, 255, 0) 8.71%, | ||||||
|  |       rgba(255, 255, 255, 0.16) 69.69% | ||||||
|  |     ), | ||||||
|  |     #806c59; | ||||||
|  |   box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25); | ||||||
|  |   border-radius: 10px 10px 0 0; | ||||||
|  |   z-index: 100; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .dropdown-content-header { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   width: 100%; | ||||||
|  |   padding: 15px 0; | ||||||
|  |   font-weight: 700; | ||||||
|  |   font-size: 18px; | ||||||
|  |   line-height: 21px; | ||||||
|  |   text-align: center; | ||||||
|  |   color: #ffffff; | ||||||
|  |   border-bottom: 1px solid #ccc; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .dropdown-content-list { | ||||||
|  |   /*52px - height of the header*/ | ||||||
|  |   height: calc(675px - 52px); | ||||||
|  |   padding-right: 35px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .alphabet-list { | ||||||
|  |   position: fixed; | ||||||
|  |   right: 35px; | ||||||
|  |   bottom: 0; | ||||||
|  |   padding: 0; | ||||||
|  |   list-style: none; | ||||||
|  |  | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   justify-content: space-between; | ||||||
|  |   /*52px - height of the header*/ | ||||||
|  |   /*28px - top&bottom paddings from .attraction-list*/ | ||||||
|  |   height: calc(675px - 52px - 28px); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .alphabet-list li { | ||||||
|  |   text-align: center; | ||||||
|  |   font-weight: 400; | ||||||
|  |   font-size: 14px; | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  |   flex-grow: 1; | ||||||
|  |   width: 35px; | ||||||
|  |   color: #ffffff; | ||||||
|  |   cursor: pointer; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .alphabet-list li:first-child { | ||||||
|  |   padding-top: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .attraction-list { | ||||||
|  |   display: grid; | ||||||
|  |   grid-template-columns: repeat(3, 1fr); | ||||||
|  |   grid-auto-rows: 150px; | ||||||
|  |   gap: 24px 0; | ||||||
|  |   padding: 14px 0; | ||||||
|  |   margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .attraction-list li { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   flex-direction: column; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .attraction-list li:hover { | ||||||
|  |   cursor: pointer; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .attraction-list li .dropdown-list-item__image-wrapper { | ||||||
|  |   background: #ffffff; | ||||||
|  |   border-radius: 50%; | ||||||
|  |   width: 96px; | ||||||
|  |   height: 96px; | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .attraction-list li .dropdown-list-item__image-wrapper img { | ||||||
|  |   max-width: 60px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .attraction-list li span { | ||||||
|  |   width: 150px; | ||||||
|  |   max-height: 40px; | ||||||
|  |   text-overflow: ellipsis; | ||||||
|  |   overflow: hidden; | ||||||
|  |   margin-top: 8px; | ||||||
|  |  | ||||||
|  |   font-weight: 600; | ||||||
|  |   font-size: 16px; | ||||||
|  |   line-height: 19px; | ||||||
|  |   text-align: center; | ||||||
|  |   letter-spacing: 0.02em; | ||||||
|  |  | ||||||
|  |   color: #ffffff; | ||||||
|  | } | ||||||
| @@ -0,0 +1,127 @@ | |||||||
|  | import { useServerLocalization } from "@mt/i18n"; | ||||||
|  |  | ||||||
|  | import React, { useCallback, useEffect, useRef, useState } from "react"; | ||||||
|  | import "./AllAttractionsDropdown.css"; | ||||||
|  | import { FormattedMessage } from "react-intl"; | ||||||
|  |  | ||||||
|  | import { useGetAttractionList } from "./useGetAttractionList"; | ||||||
|  | import { TouchScrollWrapper } from "@mt/lib"; | ||||||
|  |  | ||||||
|  | interface AllAttractionsDropdownProps { | ||||||
|  |   isIdleMode: boolean; | ||||||
|  |   handleAttractionClick: (id: string) => void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const AllAttractionsDropdown = React.memo( | ||||||
|  |   ({ isIdleMode, handleAttractionClick }: AllAttractionsDropdownProps) => { | ||||||
|  |     const { data: attractions = [] } = useGetAttractionList(); | ||||||
|  |  | ||||||
|  |     const [isOpen, setIsOpen] = useState(false); | ||||||
|  |     const attractionsListRef = useRef<HTMLUListElement>(null); | ||||||
|  |     const localizeText = useServerLocalization(); | ||||||
|  |     const letters = attractions | ||||||
|  |       .sort((a, b) => | ||||||
|  |         localizeText(a.name) | ||||||
|  |           .toLowerCase() | ||||||
|  |           .localeCompare(localizeText(b.name).toLowerCase()) | ||||||
|  |       ) | ||||||
|  |       .reduce((acc: string[], cur) => { | ||||||
|  |         const firstLetter = localizeText(cur.name).charAt(0).toUpperCase(); | ||||||
|  |  | ||||||
|  |         if (!acc.includes(firstLetter)) { | ||||||
|  |           return acc.concat(firstLetter); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return acc; | ||||||
|  |       }, []); | ||||||
|  |  | ||||||
|  |     const handleLetterClick = useCallback( | ||||||
|  |       (letter: string) => { | ||||||
|  |         const attractionByFirstLetter = attractions.find((it) => | ||||||
|  |           localizeText(it.name).startsWith(letter) | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         if (attractionByFirstLetter) { | ||||||
|  |           const item = attractionsListRef.current?.querySelector( | ||||||
|  |             `#attraction-${attractionByFirstLetter.id}` | ||||||
|  |           ); | ||||||
|  |  | ||||||
|  |           item?.scrollIntoView({ | ||||||
|  |             behavior: "smooth", | ||||||
|  |             block: "end", | ||||||
|  |             inline: "nearest", | ||||||
|  |           }); | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       [attractions, localizeText] | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     useEffect(() => { | ||||||
|  |       if (isIdleMode) setIsOpen(false); | ||||||
|  |     }, [isIdleMode]); | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |       <div className="dropdown"> | ||||||
|  |         <button | ||||||
|  |           className="dropdown-button" | ||||||
|  |           onPointerUp={() => setIsOpen(!isOpen)} | ||||||
|  |         > | ||||||
|  |           <Icons.ChevronIcon className="dropdown-icon" /> | ||||||
|  |  | ||||||
|  |           <div className="g-flex__item"> | ||||||
|  |             <FormattedMessage id="attractions" /> | ||||||
|  |           </div> | ||||||
|  |         </button> | ||||||
|  |  | ||||||
|  |         {isOpen && ( | ||||||
|  |           <div className="dropdown-content"> | ||||||
|  |             <div | ||||||
|  |               className="dropdown-content-header" | ||||||
|  |               onPointerUp={() => setIsOpen(!isOpen)} | ||||||
|  |             > | ||||||
|  |               <Icons.ChevronIcon className="dropdown-icon dropdown-icon--opened" /> | ||||||
|  |  | ||||||
|  |               <div className="g-flex__item"> | ||||||
|  |                 <FormattedMessage id="attractions" /> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <TouchScrollWrapper className="dropdown-content-list"> | ||||||
|  |               <ul className="attraction-list" ref={attractionsListRef}> | ||||||
|  |                 {attractions.map((attraction) => ( | ||||||
|  |                   <li | ||||||
|  |                     id={`attraction-${attraction.id}`} | ||||||
|  |                     key={attraction.id} | ||||||
|  |                     className="dropdown-list-item" | ||||||
|  |                     onPointerUp={() => { | ||||||
|  |                       setIsOpen(!isOpen); | ||||||
|  |                       handleAttractionClick(attraction.id); | ||||||
|  |                     }} | ||||||
|  |                   > | ||||||
|  |                     <div className="dropdown-list-item__image-wrapper"> | ||||||
|  |                       <img | ||||||
|  |                         src={attraction.iconUrl} | ||||||
|  |                         alt={localizeText(attraction.name)} | ||||||
|  |                       /> | ||||||
|  |                     </div> | ||||||
|  |                     <span>{localizeText(attraction.name)}</span> | ||||||
|  |                   </li> | ||||||
|  |                 ))} | ||||||
|  |               </ul> | ||||||
|  |             </TouchScrollWrapper> | ||||||
|  |  | ||||||
|  |             <ul className="alphabet-list"> | ||||||
|  |               {letters.map((letter) => ( | ||||||
|  |                 <li key={letter} onPointerUp={() => handleLetterClick(letter)}> | ||||||
|  |                   <span>{letter}</span> | ||||||
|  |                 </li> | ||||||
|  |               ))} | ||||||
|  |             </ul> | ||||||
|  |           </div> | ||||||
|  |         )} | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | export default AllAttractionsDropdown; | ||||||
| @@ -0,0 +1,36 @@ | |||||||
|  | import { useQuery, UseQueryResult } from 'react-query'; | ||||||
|  | import { LocalizedString } from '@mt/i18n'; | ||||||
|  | import { useEventQuery } from '@mt/utils'; | ||||||
|  | import { useEffect } from 'react'; | ||||||
|  |  | ||||||
|  | interface Attraction { | ||||||
|  |   id: string; | ||||||
|  |   name: LocalizedString; | ||||||
|  |   iconUrl: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function useGetAttractionList(): UseQueryResult<Attraction[]> { | ||||||
|  |   const { data, isSuccess } = useEventQuery('/widgets/attraction-with-details-list/events', [ | ||||||
|  |     'REFRESH_DATA', | ||||||
|  |   ]); | ||||||
|  |  | ||||||
|  |   const attractionListQuery = useQuery<Attraction[]>( | ||||||
|  |     ['getAttractionList'], | ||||||
|  |     async () => | ||||||
|  |       await fetch('https://localhost:8443/widgets/attraction-with-details-list/data') | ||||||
|  |         .then((res) => res.json()) | ||||||
|  |         .then(({ touristAttractions }) => touristAttractions), | ||||||
|  |     { | ||||||
|  |       refetchOnWindowFocus: false, | ||||||
|  |       refetchOnMount: false, | ||||||
|  |     } | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (isSuccess && data.length) { | ||||||
|  |       attractionListQuery.refetch(); | ||||||
|  |     } | ||||||
|  |   }, [data, isSuccess]); | ||||||
|  |  | ||||||
|  |   return attractionListQuery; | ||||||
|  | } | ||||||
| @@ -0,0 +1,158 @@ | |||||||
|  | import React, { useState, useEffect, useRef, useCallback } from 'react'; | ||||||
|  | import { AttractionWidget } from '@mt/components'; | ||||||
|  | import AllAttractionsDropdown from '../all-attractions-dropdown/all-attractions-dropdown'; | ||||||
|  |  | ||||||
|  | import './AttractionsWidgetContainer.css'; | ||||||
|  | import { useLightboxContext } from '@mt/components'; | ||||||
|  | // TODO: resolve circular deps | ||||||
|  | import { ArticleBase, PhotoSphereLightboxData } from '@mt/common-types'; | ||||||
|  | import { Article } from '@front/types'; | ||||||
|  |  | ||||||
|  | const POLL_INTERVAL_MS = 5000; | ||||||
|  | const IDLE_TIME_MS = 2 * 60 * 1000; // Idle time in milliseconds before restarting polling (2 minutes) | ||||||
|  | // const IDLE_TIME_MS = 15 * 1000; // debug | ||||||
|  |  | ||||||
|  | export function AttractionWidgetContainer() { | ||||||
|  |   const [touristArticles, setTouristArticles] = useState<ArticleBase[] | null>(null); | ||||||
|  |   const [error, setError] = useState<TypeError | null>(null); | ||||||
|  |   const [isPolling, setIsPolling] = useState<boolean>(true); | ||||||
|  |   const [isIdleMode, setIdleMode] = useState<boolean>(true); | ||||||
|  |   const idleTimeoutRef = useRef<NodeJS.Timeout | null>(null); | ||||||
|  |   const containerRef = useRef<HTMLDivElement>(null); | ||||||
|  |   const pollTimeoutRef = useRef<NodeJS.Timeout | null>(null); | ||||||
|  |   const abortControllerRef = useRef<AbortController | null>(null); | ||||||
|  |  | ||||||
|  |   const { closeLightbox } = useLightboxContext<PhotoSphereLightboxData>(); | ||||||
|  |  | ||||||
|  |   const startPolling = useCallback(async () => { | ||||||
|  |     closeLightbox(); | ||||||
|  |     setIsPolling(true); | ||||||
|  |  | ||||||
|  |     abortControllerRef.current = new AbortController(); | ||||||
|  |     const poll = async (attempt = 1) => { | ||||||
|  |       try { | ||||||
|  |         const response = await fetch('https://localhost:8443/widgets/attraction-details/events', { | ||||||
|  |           signal: abortControllerRef.current?.signal, | ||||||
|  |         }).then((resp) => resp.json()); | ||||||
|  |  | ||||||
|  |         // if we receive empty array - preserve previous attraction on the screen | ||||||
|  |         if (response?.[0]) { | ||||||
|  |           const [{ touristAttractionInfoPages }] = response; | ||||||
|  |  | ||||||
|  |           const articles: ArticleBase[] = touristAttractionInfoPages.map( | ||||||
|  |             ({ title: name, ...rest }: Article) => ({ name, ...rest }) | ||||||
|  |           ); | ||||||
|  |  | ||||||
|  |           setTouristArticles(articles); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         setError(null); | ||||||
|  |  | ||||||
|  |         if (isPolling) { | ||||||
|  |           // Schedule the next poll | ||||||
|  |           pollTimeoutRef.current = setTimeout(poll, POLL_INTERVAL_MS); | ||||||
|  |         } | ||||||
|  |       } catch (error: unknown) { | ||||||
|  |         if ((error as TypeError).name === 'AbortError') { | ||||||
|  |           console.log('Request was cancelled'); | ||||||
|  |         } else { | ||||||
|  |           setError(error as TypeError); | ||||||
|  |  | ||||||
|  |           if (isPolling && attempt < 3) { | ||||||
|  |             // Schedule the next poll | ||||||
|  |             pollTimeoutRef.current = setTimeout(() => poll(attempt + 1), POLL_INTERVAL_MS); | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     await poll(); | ||||||
|  |  | ||||||
|  |     return () => { | ||||||
|  |       abortControllerRef.current?.abort(); | ||||||
|  |     }; | ||||||
|  |   }, [isPolling]); | ||||||
|  |  | ||||||
|  |   const stopPolling = () => { | ||||||
|  |     setIsPolling(false); | ||||||
|  |     abortControllerRef.current?.abort(); | ||||||
|  |  | ||||||
|  |     if (pollTimeoutRef.current) { | ||||||
|  |       clearTimeout(pollTimeoutRef.current); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleUserAction = (e: Event) => { | ||||||
|  |     const widgetContainer = containerRef.current; | ||||||
|  |  | ||||||
|  |     if (!widgetContainer || !e.target) return; | ||||||
|  |     if (!widgetContainer.contains(e.target as Node)) return; | ||||||
|  |  | ||||||
|  |     e.stopPropagation(); | ||||||
|  |     resetIdleMode(); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleAttractionSelect = useCallback(async (id: string) => { | ||||||
|  |     const { touristAttractionInfoPages } = await fetch( | ||||||
|  |       'https://localhost:8443/widgets/attraction-details/data-by-params?' + | ||||||
|  |         new URLSearchParams({ touristAttractionId: id }) | ||||||
|  |     ).then((resp) => resp.json()); | ||||||
|  |  | ||||||
|  |     resetIdleMode(); | ||||||
|  |  | ||||||
|  |     const articles: ArticleBase[] = touristAttractionInfoPages.map( | ||||||
|  |       ({ title: name, ...rest }: Article) => ({ name, ...rest }) | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     setTouristArticles(articles); | ||||||
|  |     // https://tracker.yandex.ru/TS-32 | ||||||
|  |     document.querySelector('.widget-text.active')!.scrollTop = 0; | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   const resetIdleMode = () => { | ||||||
|  |     abortControllerRef.current?.abort(); // cancel ongoing requests | ||||||
|  |     idleTimeoutRef.current && clearTimeout(idleTimeoutRef.current); // reset idle timeout | ||||||
|  |     setIdleMode(false); | ||||||
|  |  | ||||||
|  |     idleTimeoutRef.current = setTimeout(() => setIdleMode(true), IDLE_TIME_MS); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     isIdleMode ? startPolling() : stopPolling(); | ||||||
|  |  | ||||||
|  |     return stopPolling; | ||||||
|  |   }, [isIdleMode, startPolling]); | ||||||
|  |  | ||||||
|  |   // Attach event listeners to stop polling on user actions | ||||||
|  |   useEffect(() => { | ||||||
|  |     window.addEventListener('click', handleUserAction); | ||||||
|  |     window.addEventListener('scroll', handleUserAction); | ||||||
|  |  | ||||||
|  |     return () => { | ||||||
|  |       window.removeEventListener('click', handleUserAction); | ||||||
|  |       window.removeEventListener('scroll', handleUserAction); | ||||||
|  |  | ||||||
|  |       if (idleTimeoutRef.current) { | ||||||
|  |         clearTimeout(idleTimeoutRef.current); | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  |   }, [containerRef]); | ||||||
|  |  | ||||||
|  |   return error ? ( | ||||||
|  |     <p>Error: {error.message}</p> | ||||||
|  |   ) : ( | ||||||
|  |     touristArticles && ( | ||||||
|  |       <div | ||||||
|  |         className="attractions-widget-container g-flex-column g-flex--justify-end" | ||||||
|  |         ref={containerRef} | ||||||
|  |       > | ||||||
|  |         <AttractionWidget articles={touristArticles} isIdleMode={isIdleMode} /> | ||||||
|  |  | ||||||
|  |         <AllAttractionsDropdown | ||||||
|  |           isIdleMode={isIdleMode} | ||||||
|  |           handleAttractionClick={handleAttractionSelect} | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     ) | ||||||
|  |   ); | ||||||
|  | } | ||||||
| @@ -0,0 +1,4 @@ | |||||||
|  | .attractions-widget-container { | ||||||
|  |   padding: 32px 32px 0 0; | ||||||
|  |   height: 100%; | ||||||
|  | } | ||||||
							
								
								
									
										70
									
								
								src/preview /components/dashboard/Dashboard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								src/preview /components/dashboard/Dashboard.tsx
									
									
									
									
									
										Normal 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> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										35
									
								
								src/preview /components/main/MainScreen.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/preview /components/main/MainScreen.tsx
									
									
									
									
									
										Normal 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 /> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										52
									
								
								src/preview /components/main/useBackendStatus.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/preview /components/main/useBackendStatus.ts
									
									
									
									
									
										Normal 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 }; | ||||||
|  | }; | ||||||
							
								
								
									
										27
									
								
								src/preview /components/main/useSplashScreenStatus.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/preview /components/main/useSplashScreenStatus.ts
									
									
									
									
									
										Normal 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 }; | ||||||
|  | }; | ||||||
| @@ -0,0 +1,108 @@ | |||||||
|  | import cn from 'classnames'; | ||||||
|  | import { HTMLAttributes, useCallback, useId, useMemo, useState } from 'react'; | ||||||
|  | import { FormattedMessage } from 'react-intl'; | ||||||
|  | import { TouchScrollWrapper, TransportIcon } from '@mt/components'; | ||||||
|  | import { uuid } from '@mt/common-types'; | ||||||
|  | import { useServerLocalization } from '@mt/i18n'; | ||||||
|  |  | ||||||
|  | import { Attraction, Station } from '../../nav-widget.interface'; | ||||||
|  | import { NavAccordion, NavAccordionSummary, NavAccordionDetails } from '../../nav-widget.styles'; | ||||||
|  | import { getDistanceFormatter } from '../get-distance-formatter'; | ||||||
|  |  | ||||||
|  | const simulateClick = (id: uuid) => { | ||||||
|  |   document.getElementById(`accordSummary_${id}`)?.click(); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export type NestedItems = Array< | ||||||
|  |   Attraction & Omit<Station, 'type'> & Partial<Pick<Station, 'type'>> | ||||||
|  | >; | ||||||
|  | export interface AccordionItem | ||||||
|  |   extends Omit<Station | Attraction, 'nearbyTouristAttractions' | 'nearbyTransportStops'> { | ||||||
|  |   nestedItems: NestedItems; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface Props extends HTMLAttributes<HTMLDivElement> { | ||||||
|  |   titleId: string; | ||||||
|  |   isOpened: boolean; | ||||||
|  |   items: AccordionItem[]; | ||||||
|  |   onExpandChange?: (isExpanded: boolean, itemId: uuid) => void; | ||||||
|  |   onNestedItemClick?: (itemId: uuid) => void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const AccordionListTab = (props: Props) => { | ||||||
|  |   const { titleId, isOpened, items, onExpandChange, onNestedItemClick, ...args } = props; | ||||||
|  |   const localizeText = useServerLocalization(); | ||||||
|  |   const formatDistance = getDistanceFormatter(localizeText); | ||||||
|  |   const [expandedItem, setExpandedItem] = useState<uuid | null>(null); | ||||||
|  |   const scrollToExpanded = useCallback((id: uuid) => { | ||||||
|  |     const expandedEl = document.getElementById(`accordWrapper_${id}`); | ||||||
|  |  | ||||||
|  |     expandedEl?.scrollIntoView({ | ||||||
|  |       behavior: 'smooth', | ||||||
|  |       block: 'nearest', | ||||||
|  |       inline: 'nearest', | ||||||
|  |     }); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   const tabClasses = useMemo( | ||||||
|  |     () => | ||||||
|  |       cn('g-flex-column__item-fixed g-flex-column tab-container', { | ||||||
|  |         'tab-container--opened': isOpened, | ||||||
|  |       }), | ||||||
|  |     [isOpened] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const isExpanded = useCallback((id: uuid) => id === expandedItem, [expandedItem]); | ||||||
|  |  | ||||||
|  |   const handleChange = (isExpanded: boolean, itemId: uuid) => { | ||||||
|  |     setExpandedItem(isExpanded ? itemId : null); | ||||||
|  |     onExpandChange?.(isExpanded, itemId); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className={tabClasses} {...args}> | ||||||
|  |       <div className="g-flex-column__item-fixed tab-title"> | ||||||
|  |         <FormattedMessage id={titleId} /> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <TouchScrollWrapper className="g-flex-column__item"> | ||||||
|  |         {items.map(({ id, name, nestedItems }) => ( | ||||||
|  |           <div className="tab-list__item" id={`accordWrapper_${id}`} key={id}> | ||||||
|  |             <div className="tab-list__item-expander" onPointerUp={() => simulateClick(id)} /> | ||||||
|  |  | ||||||
|  |             <NavAccordion | ||||||
|  |               onTransitionEnd={() => isExpanded(id) && scrollToExpanded(id)} | ||||||
|  |               disabled={!nestedItems.length} | ||||||
|  |               expanded={isExpanded(id)} | ||||||
|  |               onChange={(_, expanded) => handleChange(expanded, id)} | ||||||
|  |             > | ||||||
|  |               <NavAccordionSummary id={`accordSummary_${id}`}> | ||||||
|  |                 <div className="tab-list__item-label">{localizeText(name)}</div> | ||||||
|  |               </NavAccordionSummary> | ||||||
|  |  | ||||||
|  |               {nestedItems.length && ( | ||||||
|  |                 <NavAccordionDetails> | ||||||
|  |                   {nestedItems.map(({ id: nestedId, name, type, distance }, index) => ( | ||||||
|  |                     <div | ||||||
|  |                       key={index} | ||||||
|  |                       className="g-flex tab-list__item-card" | ||||||
|  |                       onPointerUp={() => onNestedItemClick?.(nestedId)} | ||||||
|  |                     > | ||||||
|  |                       {type && ( | ||||||
|  |                         <TransportIcon className="g-flex__item-fixed stop__icon" type={type} /> | ||||||
|  |                       )} | ||||||
|  |  | ||||||
|  |                       <div className="g-flex__item">{localizeText(name)}</div> | ||||||
|  |  | ||||||
|  |                       <div className="g-flex__item-fixed distance">{formatDistance(distance)}</div> | ||||||
|  |                     </div> | ||||||
|  |                   ))} | ||||||
|  |                 </NavAccordionDetails> | ||||||
|  |               )} | ||||||
|  |             </NavAccordion> | ||||||
|  |           </div> | ||||||
|  |         ))} | ||||||
|  |       </TouchScrollWrapper> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,5 @@ | |||||||
|  | .root { | ||||||
|  |   position: absolute; | ||||||
|  |   left: 312px; | ||||||
|  |   bottom: 130px; | ||||||
|  | } | ||||||
| @@ -0,0 +1,21 @@ | |||||||
|  | import { HTMLAttributes } from 'react'; | ||||||
|  |  | ||||||
|  | import { uuid } from '@mt/common-types'; | ||||||
|  | import { AttractionShortPreview } from '@mt/components'; | ||||||
|  | import { useGetAttractionDetails } from '../../hooks/useGetAttractionDetails'; | ||||||
|  |  | ||||||
|  | import styles from './AttractionCard.module.css'; | ||||||
|  |  | ||||||
|  | interface AttractionCardProps { | ||||||
|  |   attractionId: uuid; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function AttractionCard({ attractionId }: AttractionCardProps) { | ||||||
|  |   const { isSuccess, data: attractionDetails } = useGetAttractionDetails(attractionId); | ||||||
|  |  | ||||||
|  |   if (!(isSuccess && attractionDetails)) { | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return <AttractionShortPreview className={styles.root} {...attractionDetails} />; | ||||||
|  | } | ||||||
| @@ -0,0 +1,41 @@ | |||||||
|  | import { ButtonBase } from '@mui/material'; | ||||||
|  | import { FormattedMessage } from 'react-intl'; | ||||||
|  | import { GerbIcon } from '../../../../icons'; | ||||||
|  | import { HTMLAttributes } from 'react'; | ||||||
|  | import { NavTabs } from '../../nav-widget'; | ||||||
|  |  | ||||||
|  | interface Props extends HTMLAttributes<HTMLDivElement> { | ||||||
|  |   onOpenTab: (tabName: NavTabs) => void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const HomeTab = ({ onOpenTab, ...props }: Props) => { | ||||||
|  |   return ( | ||||||
|  |     <div className="g-flex-column" style={{ height: '100%' }} {...props}> | ||||||
|  |       <div className="g-flex-column__item-fixed"> | ||||||
|  |         <GerbIcon /> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div className="g-flex-column__item g-flex-column g-flex--justify-center"> | ||||||
|  |         <ButtonBase className="tab-button" onPointerUp={() => onOpenTab('attractionsTab')}> | ||||||
|  |           <FormattedMessage id="attractions" /> | ||||||
|  |         </ButtonBase> | ||||||
|  |  | ||||||
|  |         <ButtonBase className="tab-button" onPointerUp={() => onOpenTab('stationsTab')}> | ||||||
|  |           <FormattedMessage id="stops" /> | ||||||
|  |         </ButtonBase> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div className="g-flex-column__item-fixed" style={{ textAlign: 'center' }}> | ||||||
|  |         <img src="/assets/icons/company-logo.svg" alt="company-logo" /> | ||||||
|  |  | ||||||
|  |         <p className="slogan"> | ||||||
|  |           <FormattedMessage id="slogan" values={{ br: <br /> }} /> | ||||||
|  |         </p> | ||||||
|  |  | ||||||
|  |         <p className="hashtag"> | ||||||
|  |           <FormattedMessage id="hashtag" /> | ||||||
|  |         </p> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,29 @@ | |||||||
|  | import type { LocalizedString, LocalizeTextFn } from '@mt/i18n'; | ||||||
|  |  | ||||||
|  | const meterLabel: LocalizedString = { | ||||||
|  |   ru: 'м', | ||||||
|  |   en: 'm', | ||||||
|  |   zh: '米', | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const kilometerLabel: LocalizedString = { | ||||||
|  |   ru: 'км', | ||||||
|  |   en: 'km', | ||||||
|  |   zh: '公里', | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export function getDistanceFormatter( | ||||||
|  |   localizeText: LocalizeTextFn | ||||||
|  | ): (distance: number | undefined) => string { | ||||||
|  |   return (distance) => { | ||||||
|  |     if (!distance) { | ||||||
|  |       return ''; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (distance < 1000) { | ||||||
|  |       return `${distance} ${localizeText(meterLabel)}`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return `${(distance / 1000).toFixed(2)} ${localizeText(kilometerLabel)}`; | ||||||
|  |   }; | ||||||
|  | } | ||||||
							
								
								
									
										3
									
								
								src/preview /components/nav-widget/components/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/preview /components/nav-widget/components/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | export * from './HomeTab/HomeTab'; | ||||||
|  | export * from './AttractionCard/AttractionCard'; | ||||||
|  | export * from './AccordionListTab/AccordionListTab'; | ||||||
							
								
								
									
										1
									
								
								src/preview /components/nav-widget/hooks/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/preview /components/nav-widget/hooks/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | export * from './useGetAttractions'; | ||||||
| @@ -0,0 +1,24 @@ | |||||||
|  | import { UseQueryResult, useQuery } from 'react-query'; | ||||||
|  | import { uuid } from '@mt/common-types'; | ||||||
|  | import { AttractionDetailsBE } from '../nav-widget.interface'; | ||||||
|  | import { AttractionShortPreviewProps } from '@mt/components'; | ||||||
|  |  | ||||||
|  | export function useGetAttractionDetails( | ||||||
|  |   touristAttractionId: uuid | ||||||
|  | ): UseQueryResult<AttractionShortPreviewProps> { | ||||||
|  |   return useQuery(['getAttractionDetails', touristAttractionId], async () => { | ||||||
|  |     const { | ||||||
|  |       touristAttractionAddress: subtitle, | ||||||
|  |       touristAttractionDescription: content, | ||||||
|  |       touristAttractionName: title, | ||||||
|  |       touristAttractionImageUrl: img, | ||||||
|  |     }: AttractionDetailsBE = await fetch( | ||||||
|  |       'https://localhost:8443/widgets/attraction-info/data-by-params?' + | ||||||
|  |         new URLSearchParams({ | ||||||
|  |           touristAttractionId: touristAttractionId as string, | ||||||
|  |         }) | ||||||
|  |     ).then((res) => res.json()); | ||||||
|  |  | ||||||
|  |     return { img, title, subtitle, content }; | ||||||
|  |   }); | ||||||
|  | } | ||||||
| @@ -0,0 +1,28 @@ | |||||||
|  | import { UseQueryResult, useQuery } from 'react-query'; | ||||||
|  | import { Attraction } from '../nav-widget.interface'; | ||||||
|  | import { useEventQuery } from '@mt/utils'; | ||||||
|  | import { useEffect } from 'react'; | ||||||
|  |  | ||||||
|  | export function useGetAttractions(): UseQueryResult<Attraction[]> { | ||||||
|  |   const { data, isSuccess } = useEventQuery( | ||||||
|  |     // 'getAttractionsEvents', | ||||||
|  |     '/widgets/attraction-list/events', | ||||||
|  |     ['REFRESH_DATA'] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const attractionQuery = useQuery( | ||||||
|  |     'getAttractions', | ||||||
|  |     async () => | ||||||
|  |       await fetch('https://localhost:8443/widgets/attraction-list/data') | ||||||
|  |         .then((res) => res.json()) | ||||||
|  |         .then(({ touristAttractions }) => touristAttractions) | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (isSuccess && data.length) { | ||||||
|  |       attractionQuery.refetch(); | ||||||
|  |     } | ||||||
|  |   }, [data, isSuccess]); | ||||||
|  |  | ||||||
|  |   return attractionQuery; | ||||||
|  | } | ||||||
							
								
								
									
										28
									
								
								src/preview /components/nav-widget/hooks/useGetStations.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/preview /components/nav-widget/hooks/useGetStations.ts
									
									
									
									
									
										Normal 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; | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								src/preview /components/nav-widget/nav-widget-container.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/preview /components/nav-widget/nav-widget-container.tsx
									
									
									
									
									
										Normal 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} />; | ||||||
|  | } | ||||||
							
								
								
									
										24
									
								
								src/preview /components/nav-widget/nav-widget.interface.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/preview /components/nav-widget/nav-widget.interface.ts
									
									
									
									
									
										Normal 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; | ||||||
|  | } | ||||||
							
								
								
									
										169
									
								
								src/preview /components/nav-widget/nav-widget.styles.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								src/preview /components/nav-widget/nav-widget.styles.tsx
									
									
									
									
									
										Normal 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%; | ||||||
|  |   } | ||||||
|  | `; | ||||||
							
								
								
									
										105
									
								
								src/preview /components/nav-widget/nav-widget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								src/preview /components/nav-widget/nav-widget.tsx
									
									
									
									
									
										Normal 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> | ||||||
|  |   ); | ||||||
|  | } | ||||||
| @@ -0,0 +1,76 @@ | |||||||
|  | .operative-info-widget { | ||||||
|  |   position: absolute; | ||||||
|  |   bottom: 12px; | ||||||
|  |   right: 32px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .info-btn { | ||||||
|  |   position: absolute; | ||||||
|  |   bottom: 0; | ||||||
|  |   right: 0; | ||||||
|  |   width: 48px; | ||||||
|  |   height: 48px; | ||||||
|  |   border: 0; | ||||||
|  |   padding: 0; | ||||||
|  |   background: none; | ||||||
|  |   cursor: pointer; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .popup { | ||||||
|  |   position: absolute; | ||||||
|  |   right: 48px; /*btn size*/ | ||||||
|  |   bottom: 0; | ||||||
|  |  | ||||||
|  |   min-width: 371px; | ||||||
|  |   padding: 16px; | ||||||
|  |   margin-right: 16px; | ||||||
|  |   border-radius: 10px; | ||||||
|  |  | ||||||
|  |   background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), | ||||||
|  |     rgba(179, 165, 152, 0.4); | ||||||
|  |   box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12); | ||||||
|  |  | ||||||
|  |   font-weight: 700; | ||||||
|  |   font-size: 18px; | ||||||
|  |   line-height: 150%; | ||||||
|  |   color: #ffffff; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .popup-title { | ||||||
|  |   margin-bottom: 4px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .transfers-list, | ||||||
|  | .alerts-list { | ||||||
|  |   margin: 0; | ||||||
|  |   padding: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .alerts-list { | ||||||
|  |   margin-bottom: 20px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .transfer-list-item, | ||||||
|  | .alert-list-item { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   gap: 9px; | ||||||
|  |   list-style: none; | ||||||
|  |   padding: 6px 12px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .alert-name { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .transfer-type-icon, | ||||||
|  | .transfer-type-icon svg, | ||||||
|  | .alert-icon { | ||||||
|  |   width: 23px; | ||||||
|  |   height: 23px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .alert-icon { | ||||||
|  |   margin-right: 6px; | ||||||
|  | } | ||||||
| @@ -0,0 +1,15 @@ | |||||||
|  | import { LocalizedString } from '@mt/i18n'; | ||||||
|  |  | ||||||
|  | export interface AlertMessage { | ||||||
|  |   key: string; | ||||||
|  |   bgColor: string; | ||||||
|  |   fontSize: number; | ||||||
|  |   iconUrl: string; | ||||||
|  |   order: number; | ||||||
|  |   text: LocalizedString; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface AlertEvent { | ||||||
|  |   ['@type']: 'UPDATE_AVAILABLE_ALERTS'; | ||||||
|  |   msgs: AlertMessage[]; | ||||||
|  | } | ||||||
| @@ -0,0 +1,73 @@ | |||||||
|  | import React, { useEffect, useState } from 'react'; | ||||||
|  | import { Icons, TransportIcon } from '@mt/components'; | ||||||
|  | import './OperativeInfoWidget.css'; | ||||||
|  | import { useOperativeInfo } from './useOperativeInfo'; | ||||||
|  | import { FormattedMessage } from 'react-intl'; | ||||||
|  | import { useServerLocalization } from '@mt/i18n'; | ||||||
|  | import { AlertMessage } from './alert.interface'; | ||||||
|  | import { TransferItem } from './transfer.interface'; | ||||||
|  |  | ||||||
|  | export function OperativeInfoWidget() { | ||||||
|  |   const [isOpen, setIsOpen] = useState<boolean>(false); | ||||||
|  |   const { transfers, alerts } = useOperativeInfo(); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     setIsOpen(transfers.length > 0 || alerts.length > 0); | ||||||
|  |   }, [transfers, alerts]); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="operative-info-widget"> | ||||||
|  |       <button className="info-btn" onPointerUp={() => setIsOpen(!isOpen)}> | ||||||
|  |         <Icons.InfoBtn /> | ||||||
|  |       </button> | ||||||
|  |  | ||||||
|  |       {isOpen && ( | ||||||
|  |         <div className="popup"> | ||||||
|  |           <AlertsList alerts={alerts} /> | ||||||
|  |           <TransfersList transfers={transfers} /> | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function AlertsList({ alerts }: { alerts: AlertMessage[] }) { | ||||||
|  |   const localizeText = useServerLocalization(); | ||||||
|  |  | ||||||
|  |   return alerts.length ? ( | ||||||
|  |     <ul className="alerts-list"> | ||||||
|  |       {alerts.map(({ key, bgColor, fontSize, iconUrl, text }) => ( | ||||||
|  |         <li | ||||||
|  |           className="alert-list-item" | ||||||
|  |           key={key} | ||||||
|  |           style={{ backgroundColor: bgColor, fontSize: fontSize }} | ||||||
|  |         > | ||||||
|  |           <span className="alert-name"> | ||||||
|  |             {iconUrl && <img src={iconUrl} alt="" className="alert-icon" />} | ||||||
|  |             {localizeText(text)} | ||||||
|  |           </span> | ||||||
|  |         </li> | ||||||
|  |       ))} | ||||||
|  |     </ul> | ||||||
|  |   ) : null; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function TransfersList({ transfers }: { transfers: TransferItem[] }) { | ||||||
|  |   return transfers.length ? ( | ||||||
|  |     <> | ||||||
|  |       <div className="popup-title"> | ||||||
|  |         <FormattedMessage id="available-transfers" /> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <ul className="transfers-list"> | ||||||
|  |         {transfers.map(({ type, label }) => ( | ||||||
|  |           <li className="transfer-list-item" key={type + label}> | ||||||
|  |             <TransportIcon className="transfer-type-icon" type={type} /> | ||||||
|  |  | ||||||
|  |             <span className="transfer-name">{label}</span> | ||||||
|  |           </li> | ||||||
|  |         ))} | ||||||
|  |       </ul> | ||||||
|  |     </> | ||||||
|  |   ) : null; | ||||||
|  | } | ||||||
| @@ -0,0 +1,19 @@ | |||||||
|  | import { Transfer } from '@front/types'; | ||||||
|  | import { TransportType } from '@mt/common-types'; | ||||||
|  |  | ||||||
|  | export interface PublicRoute { | ||||||
|  |   type: TransportType; | ||||||
|  |   // Public route number string (ex.: '43') | ||||||
|  |   number: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface TransferEvent { | ||||||
|  |   ['@type']: 'UPDATE_AVAILABLE_TRANSFERS'; | ||||||
|  |   publicRoutes: PublicRoute[]; | ||||||
|  |   stations: Transfer[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface TransferItem { | ||||||
|  |   type: TransportType; | ||||||
|  |   label: string; | ||||||
|  | } | ||||||
| @@ -0,0 +1,83 @@ | |||||||
|  | import { Dispatch, useEffect, useState } from 'react'; | ||||||
|  | import { EventQueryData, useEventQuery } from '@mt/utils'; | ||||||
|  | import { TransportType } from '@mt/common-types'; | ||||||
|  | import { LocalizeTextFn, useServerLocalization } from '@mt/i18n'; | ||||||
|  | import { TransferEvent, PublicRoute, TransferItem } from './transfer.interface'; | ||||||
|  | import { AlertEvent, AlertMessage } from './alert.interface'; | ||||||
|  |  | ||||||
|  | export const useOperativeInfo = () => { | ||||||
|  |   const localizeText = useServerLocalization(); | ||||||
|  |   const [transfers, setTransfers] = useState<TransferItem[]>([]); | ||||||
|  |   const [alerts, setAlerts] = useState<AlertMessage[]>([]); | ||||||
|  |  | ||||||
|  |   const { data: events, isSuccess } = useEventQuery<TransferEvent | AlertEvent>( | ||||||
|  |     '/widgets/operative-info/events', | ||||||
|  |     ['UPDATE_AVAILABLE_TRANSFERS', 'UPDATE_AVAILABLE_ALERTS'] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (isSuccess && events.length) { | ||||||
|  |       // prettier-ignore | ||||||
|  |       const transferEvent = events.filter((e) => e['@type'] === 'UPDATE_AVAILABLE_TRANSFERS').at(-1) as EventQueryData<TransferEvent>; | ||||||
|  |       // prettier-ignore | ||||||
|  |       const alertEvent = events.filter((e) => e['@type'] === 'UPDATE_AVAILABLE_ALERTS').at(-1) as EventQueryData<AlertEvent>; | ||||||
|  |  | ||||||
|  |       updateTransfers(transferEvent, setTransfers, localizeText); | ||||||
|  |       updateAlerts(alertEvent, setAlerts); | ||||||
|  |     } | ||||||
|  |   }, [events, isSuccess]); | ||||||
|  |  | ||||||
|  |   return { transfers, alerts }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | function updateTransfers( | ||||||
|  |   transferEvent: EventQueryData<TransferEvent>, | ||||||
|  |   setTransfers: Dispatch<TransferItem[]>, | ||||||
|  |   localizeText: LocalizeTextFn | ||||||
|  | ) { | ||||||
|  |   if (transferEvent) { | ||||||
|  |     const { publicRoutes, stations } = transferEvent as TransferEvent; | ||||||
|  |  | ||||||
|  |     const newTransfers: TransferItem[] = [ | ||||||
|  |       ...mapPublicRoutesToTransfers(publicRoutes), | ||||||
|  |       ...stations.map(({ type, name }) => ({ | ||||||
|  |         type, | ||||||
|  |         label: localizeText(name), | ||||||
|  |       })), | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     setTransfers(newTransfers); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function updateAlerts(alertEvent: EventQueryData<AlertEvent>, setAlerts: Dispatch<AlertMessage[]>) { | ||||||
|  |   if (alertEvent) { | ||||||
|  |     const { msgs } = alertEvent as AlertEvent; | ||||||
|  |  | ||||||
|  |     const newAlerts: AlertMessage[] = [...msgs] | ||||||
|  |       .sort((a, b) => a.order - a.order) | ||||||
|  |       .map((msg) => ({ | ||||||
|  |         ...msg, | ||||||
|  |         key: crypto.randomUUID(), | ||||||
|  |       })); | ||||||
|  |  | ||||||
|  |     setAlerts(newAlerts); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function mapPublicRoutesToTransfers(routes: PublicRoute[]): TransferItem[] { | ||||||
|  |   const routeMap = routes.reduce((acc: Partial<Record<TransportType, string[]>>, item) => { | ||||||
|  |     const { type, number } = item; | ||||||
|  |  | ||||||
|  |     acc[type] = [...(acc[type] ?? []), number]; | ||||||
|  |  | ||||||
|  |     return acc; | ||||||
|  |   }, {}); | ||||||
|  |  | ||||||
|  |   return Object.entries(routeMap).flatMap(([type, numbers]) => { | ||||||
|  |     return { | ||||||
|  |       type, | ||||||
|  |       label: numbers.join(', '), | ||||||
|  |     } as TransferItem; | ||||||
|  |   }); | ||||||
|  | } | ||||||
							
								
								
									
										29
									
								
								src/preview /components/weather-widget/weather.interface.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/preview /components/weather-widget/weather.interface.ts
									
									
									
									
									
										Normal 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; | ||||||
|  | }; | ||||||
							
								
								
									
										37
									
								
								src/preview /i18n/LocaleSwitcher/LocaleSwitcher.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/preview /i18n/LocaleSwitcher/LocaleSwitcher.css
									
									
									
									
									
										Normal 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; | ||||||
|  | } | ||||||
							
								
								
									
										45
									
								
								src/preview /i18n/LocaleSwitcher/LocaleSwitcher.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/preview /i18n/LocaleSwitcher/LocaleSwitcher.tsx
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										10
									
								
								src/preview /i18n/en.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | { | ||||||
|  |   "support-of-the-government-of-spb": "With the support of the Government of St. Petersburg", | ||||||
|  |   "attractions": "Attractions", | ||||||
|  |   "stops": "Stops", | ||||||
|  |   "available-transfers": "Available transfers:", | ||||||
|  |   "hashtag": "#UsAlongTheWay", | ||||||
|  |   "slogan": "Preserving history,{br}moving into the future.", | ||||||
|  |   "loading": "Loading...", | ||||||
|  |   "close": "Close" | ||||||
|  | } | ||||||
							
								
								
									
										10
									
								
								src/preview /i18n/i18n.interface.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/preview /i18n/i18n.interface.ts
									
									
									
									
									
										Normal 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>; | ||||||
							
								
								
									
										5
									
								
								src/preview /i18n/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/preview /i18n/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | export * from './i18n.interface'; | ||||||
|  | export * from './language-loader'; | ||||||
|  | export * from './useServerLocalization'; | ||||||
|  | export * from './localization-context'; | ||||||
|  | export * from './LocaleSwitcher/LocaleSwitcher'; | ||||||
							
								
								
									
										15
									
								
								src/preview /i18n/language-loader.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/preview /i18n/language-loader.ts
									
									
									
									
									
										Normal 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 | ||||||
|  | } | ||||||
							
								
								
									
										25
									
								
								src/preview /i18n/localization-context.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/preview /i18n/localization-context.tsx
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										10
									
								
								src/preview /i18n/ru.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | { | ||||||
|  |   "support-of-the-government-of-spb": "При поддержке Правительства Санкт-Петербурга", | ||||||
|  |   "attractions": "Достопримечательности", | ||||||
|  |   "stops": "Остановки", | ||||||
|  |   "available-transfers": "Доступны пересадки:", | ||||||
|  |   "hashtag": "#ВсемПоПути", | ||||||
|  |   "slogan": "Сохраняя историю,{br}движемся в будущее.", | ||||||
|  |   "loading": "Загрузка...", | ||||||
|  |   "close": "Закрыть" | ||||||
|  | } | ||||||
							
								
								
									
										19
									
								
								src/preview /i18n/useServerLocalization.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/preview /i18n/useServerLocalization.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										10
									
								
								src/preview /i18n/zh.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | { | ||||||
|  |   "support-of-the-government-of-spb": "在聖彼得堡政府的支持下", | ||||||
|  |   "attractions": "景點", | ||||||
|  |   "stops": "停止", | ||||||
|  |   "available-transfers": "可用的轉移:", | ||||||
|  |   "hashtag": "#我们在路上", | ||||||
|  |   "slogan": "保存历史、{br}迈向未来。", | ||||||
|  |   "loading": "載入中...", | ||||||
|  |   "close": "關閉" | ||||||
|  | } | ||||||
| @@ -0,0 +1,39 @@ | |||||||
|  | .attraction-card { | ||||||
|  |   height: 415px; | ||||||
|  |   width: 315px; | ||||||
|  |   background: linear-gradient( | ||||||
|  |       113.51deg, | ||||||
|  |       rgba(255, 255, 255, 0) 8.71%, | ||||||
|  |       rgba(255, 255, 255, 0.16) 69.69% | ||||||
|  |     ), | ||||||
|  |     #806c59; | ||||||
|  |   border-radius: 10px; | ||||||
|  |  | ||||||
|  |   overflow: hidden; | ||||||
|  |   color: #ffffff; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .attraction-card__content { | ||||||
|  |   padding: 12px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .attraction-card__title { | ||||||
|  |   margin: 8px 0 0 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .attraction-card__text { | ||||||
|  |   margin: 30px 0 0 0; | ||||||
|  |   white-space: pre-wrap; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .attraction-card__subtitle { | ||||||
|  |   margin: 8px 0 0 0; | ||||||
|  |   font-weight: 400; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .attraction-card__image { | ||||||
|  |   min-width: 100%; | ||||||
|  |   max-height: 50%; | ||||||
|  |   padding: 2px; | ||||||
|  |   border-radius: 10px 10px 0 0; | ||||||
|  | } | ||||||
| @@ -0,0 +1,43 @@ | |||||||
|  | import './AttractionShortPreview.css'; | ||||||
|  |  | ||||||
|  | import { LocalizedString, useServerLocalization } from '@mt/i18n'; | ||||||
|  | import classNames from 'classnames'; | ||||||
|  | import { TouchScrollWrapper } from '../TouchScrollWrapper/TouchScrollWrapper'; | ||||||
|  | import { HTMLAttributes } from 'react'; | ||||||
|  |  | ||||||
|  | export interface AttractionShortPreviewProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> { | ||||||
|  |   img: string; | ||||||
|  |   title: LocalizedString; | ||||||
|  |   subtitle: LocalizedString; | ||||||
|  |   content: LocalizedString; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function AttractionShortPreview({ | ||||||
|  |   img, | ||||||
|  |   title, | ||||||
|  |   subtitle, | ||||||
|  |   content, | ||||||
|  |   className, | ||||||
|  |   ...props | ||||||
|  | }: AttractionShortPreviewProps) { | ||||||
|  |   const localizeText = useServerLocalization(); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className={classNames(className, 'attraction-card g-flex-column')} {...props}> | ||||||
|  |       {img && <img className="attraction-card__image" src={img} alt={localizeText(title)} />} | ||||||
|  |  | ||||||
|  |       <TouchScrollWrapper className="g-flex-column__item"> | ||||||
|  |         <div className="attraction-card__content"> | ||||||
|  |           <h4 className="attraction-card__title">{localizeText(title)}</h4> | ||||||
|  |  | ||||||
|  |           <h5 className="attraction-card__subtitle">{localizeText(subtitle)}</h5> | ||||||
|  |  | ||||||
|  |           <p | ||||||
|  |             className="attraction-card__text" | ||||||
|  |             dangerouslySetInnerHTML={{ __html: localizeText(content) }} | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |       </TouchScrollWrapper> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										126
									
								
								src/preview /lib/AttractionWidget/AttractionWidget.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								src/preview /lib/AttractionWidget/AttractionWidget.css
									
									
									
									
									
										Normal 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; | ||||||
|  | } | ||||||
							
								
								
									
										111
									
								
								src/preview /lib/AttractionWidget/AttractionWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								src/preview /lib/AttractionWidget/AttractionWidget.tsx
									
									
									
									
									
										Normal 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> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										52
									
								
								src/preview /lib/AttractionWidget/media/AttractionMedia.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/preview /lib/AttractionWidget/media/AttractionMedia.css
									
									
									
									
									
										Normal 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; | ||||||
|  | } | ||||||
							
								
								
									
										34
									
								
								src/preview /lib/AttractionWidget/media/AttractionMedia.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/preview /lib/AttractionWidget/media/AttractionMedia.tsx
									
									
									
									
									
										Normal 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 | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | ); | ||||||
							
								
								
									
										25
									
								
								src/preview /lib/AttractionWidget/media/ImageMedia.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/preview /lib/AttractionWidget/media/ImageMedia.tsx
									
									
									
									
									
										Normal 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" /> | ||||||
|  |     )} | ||||||
|  |   </> | ||||||
|  | ); | ||||||
							
								
								
									
										50
									
								
								src/preview /lib/AttractionWidget/media/Object3DMedia.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								src/preview /lib/AttractionWidget/media/Object3DMedia.tsx
									
									
									
									
									
										Normal 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> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										57
									
								
								src/preview /lib/AttractionWidget/media/PhotoSphereMedia.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/preview /lib/AttractionWidget/media/PhotoSphereMedia.tsx
									
									
									
									
									
										Normal 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> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										26
									
								
								src/preview /lib/AttractionWidget/media/VideoMedia.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/preview /lib/AttractionWidget/media/VideoMedia.tsx
									
									
									
									
									
										Normal 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" /> | ||||||
|  |     )} | ||||||
|  |   </> | ||||||
|  | ); | ||||||
							
								
								
									
										47
									
								
								src/preview /lib/Drawer/Drawer.styles.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/preview /lib/Drawer/Drawer.styles.tsx
									
									
									
									
									
										Normal 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); | ||||||
|  |   } | ||||||
|  | `; | ||||||
							
								
								
									
										47
									
								
								src/preview /lib/Drawer/Drawer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/preview /lib/Drawer/Drawer.tsx
									
									
									
									
									
										Normal 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> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										1
									
								
								src/preview /lib/Drawer/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/preview /lib/Drawer/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | export { Drawer } from './Drawer'; | ||||||
							
								
								
									
										244
									
								
								src/preview /lib/MapWidget/MapWidgetContext.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										244
									
								
								src/preview /lib/MapWidget/MapWidgetContext.tsx
									
									
									
									
									
										Normal 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; | ||||||
|  | }; | ||||||
| @@ -0,0 +1,25 @@ | |||||||
|  | import { TrackAttractions, TrackLine, TrackStations, TramMarker } from '../index'; | ||||||
|  | import { getMapPoint } from '../../utils'; | ||||||
|  | import { useMapWidgetContext } from '../../MapWidgetContext'; | ||||||
|  |  | ||||||
|  | export const MapContent = () => { | ||||||
|  |   const { rotateAngle, isEditMode, currentPosition, currentStation, nextStation } = | ||||||
|  |     useMapWidgetContext(); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <g className="g-transform-origin__center" style={{ transform: `rotate(${rotateAngle}deg)` }}> | ||||||
|  |       <TrackLine /> | ||||||
|  |  | ||||||
|  |       <TrackAttractions /> | ||||||
|  |  | ||||||
|  |       <TrackStations /> | ||||||
|  |  | ||||||
|  |       {!isEditMode && currentPosition && nextStation && ( | ||||||
|  |         <TramMarker | ||||||
|  |           coordinates={getMapPoint(currentPosition)} | ||||||
|  |           nextStopPoint={(currentStation ?? nextStation).pointOnMap} | ||||||
|  |         /> | ||||||
|  |       )} | ||||||
|  |     </g> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,4 @@ | |||||||
|  | .mapWidget { | ||||||
|  |   position: relative; | ||||||
|  |   z-index: 100; | ||||||
|  | } | ||||||
| @@ -0,0 +1,50 @@ | |||||||
|  | import { ComposableMap, ZoomableGroup, ZoomableGroupProps } from 'react-simple-maps'; | ||||||
|  | import styles from './MapWidget.module.css'; | ||||||
|  | import { mapCanvasProps, useMapWidgetContext } from '../../MapWidgetContext'; | ||||||
|  | import { useState } from 'react'; | ||||||
|  | import { MapContent } from './MapContent'; | ||||||
|  |  | ||||||
|  | // default coordinates for 3a route: 59.943, 30.331 | ||||||
|  | export const MapWidget = () => { | ||||||
|  |   const { onMapCenterMoved, projection, isDragMode, rotateAngle } = useMapWidgetContext(); | ||||||
|  |   const [key, setKey] = useState(42); | ||||||
|  |  | ||||||
|  |   const handleMoveEnd: ZoomableGroupProps['onMoveEnd'] = (e, d3Zoom) => { | ||||||
|  |     const { PI, cos, sin } = Math; | ||||||
|  |     const { x, y } = d3Zoom.transform; | ||||||
|  |     const { width, height } = mapCanvasProps; | ||||||
|  |  | ||||||
|  |     const alpha = (-rotateAngle * PI) / 180; | ||||||
|  |  | ||||||
|  |     const x1 = x * cos(alpha) - y * sin(alpha); | ||||||
|  |     const y1 = x * sin(alpha) + y * cos(alpha); | ||||||
|  |  | ||||||
|  |     const cX = width / 2 - x1; | ||||||
|  |     const cY = height / 2 - y1; | ||||||
|  |  | ||||||
|  |     const [lon, lat] = projection.invert?.([cX, cY]) ?? [0, 0]; | ||||||
|  |  | ||||||
|  |     onMapCenterMoved({ lon, lat }); | ||||||
|  |     setKey(-key); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <ComposableMap | ||||||
|  |       // Cast to any need due to error in react-simple-maps typings | ||||||
|  |       projection={projection as any} | ||||||
|  |       className={styles.mapWidget} | ||||||
|  |       {...mapCanvasProps} | ||||||
|  |     > | ||||||
|  |       <ZoomableGroup | ||||||
|  |         key={key} | ||||||
|  |         center={projection.center()} | ||||||
|  |         onMoveEnd={handleMoveEnd} | ||||||
|  |         filterZoomEvent={() => isDragMode} | ||||||
|  |         minZoom={1} | ||||||
|  |         maxZoom={1} | ||||||
|  |       > | ||||||
|  |         <MapContent /> | ||||||
|  |       </ZoomableGroup> | ||||||
|  |     </ComposableMap> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										1
									
								
								src/preview /lib/MapWidget/components/MapWidget/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/preview /lib/MapWidget/components/MapWidget/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | export * from './MapWidget'; | ||||||
| @@ -0,0 +1,41 @@ | |||||||
|  | .markerLarge, | ||||||
|  | .markerSmall { | ||||||
|  |   overflow: visible; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .markerLarge { | ||||||
|  |   width: 23px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .markerLarge .counter { | ||||||
|  |   transform: translate(30%, 15%); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .markerSmall { | ||||||
|  |   width: 15px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .markerSmall .counter { | ||||||
|  |   transform: translate(50%, -25%); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .icon { | ||||||
|  |   width: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .counter { | ||||||
|  |   position: absolute; | ||||||
|  |   top: 0; | ||||||
|  |   right: 0; | ||||||
|  |  | ||||||
|  |   width: 8px; | ||||||
|  |   height: 8px; | ||||||
|  |   line-height: 8px; | ||||||
|  |   text-align: center; | ||||||
|  |  | ||||||
|  |   border-radius: 50%; | ||||||
|  |   background-color: #896f58; | ||||||
|  |   font-size: 0.3rem; | ||||||
|  |   font-weight: 700; | ||||||
|  |   color: #ffffff; | ||||||
|  | } | ||||||
| @@ -0,0 +1,32 @@ | |||||||
|  | import { Marker, Point } from 'react-simple-maps'; | ||||||
|  | import { Icons } from '@mt/components'; | ||||||
|  | import { AttractionGroupIconSizeType } from '@mt/common-types'; | ||||||
|  |  | ||||||
|  | import styles from './AttractionMarker.module.css'; | ||||||
|  | import cn from 'classnames'; | ||||||
|  |  | ||||||
|  | interface Props { | ||||||
|  |   coordinates: Point; | ||||||
|  |   rotate: number; | ||||||
|  |   size: AttractionGroupIconSizeType; | ||||||
|  |   counter?: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const AttractionMarker = ({ coordinates, counter = 0, rotate, size }: Props) => { | ||||||
|  |   return ( | ||||||
|  |     <Marker coordinates={coordinates}> | ||||||
|  |       <foreignObject | ||||||
|  |         className={cn({ | ||||||
|  |           [styles.markerLarge]: size === 'LARGE', | ||||||
|  |           [styles.markerSmall]: size === 'SMALL', | ||||||
|  |         })} | ||||||
|  |       > | ||||||
|  |         <div className="g-transform-origin__center" style={{ transform: `rotate(${rotate}deg)` }}> | ||||||
|  |           <Icons.AttractionIcon className={styles.icon} /> | ||||||
|  |  | ||||||
|  |           {counter > 1 && <div className={styles.counter}>{counter}</div>} | ||||||
|  |         </div> | ||||||
|  |       </foreignObject> | ||||||
|  |     </Marker> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,25 @@ | |||||||
|  | import { AttractionMarker } from './AttractionMarker'; | ||||||
|  | import { getMapPoint } from '../../utils'; | ||||||
|  | import { useMapWidgetContext } from '../../MapWidgetContext'; | ||||||
|  |  | ||||||
|  | export const TrackAttractions = () => { | ||||||
|  |   const { attractionGroups, rotateAngle } = useMapWidgetContext(); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       {attractionGroups.map((group) => ( | ||||||
|  |         <AttractionMarker | ||||||
|  |           key={ | ||||||
|  |             group.touristAttractionsOnMap[0]?.id || | ||||||
|  |             `${group.pointOnMap.lat}:${group.pointOnMap.lon}` | ||||||
|  |           } | ||||||
|  |           coordinates={getMapPoint(group.pointOnMap)} | ||||||
|  |           // Inverse angle to compensate map rotation | ||||||
|  |           rotate={-rotateAngle} | ||||||
|  |           counter={group.touristAttractionsOnMap.length} | ||||||
|  |           size={group.iconSize} | ||||||
|  |         /> | ||||||
|  |       ))} | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1 @@ | |||||||
|  | export * from './TrackAttractions'; | ||||||
							
								
								
									
										32
									
								
								src/preview /lib/MapWidget/components/TrackLine.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/preview /lib/MapWidget/components/TrackLine.tsx
									
									
									
									
									
										Normal 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} | ||||||
|  |       /> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,8 @@ | |||||||
|  | // TODO: resolve circular deps | ||||||
|  | import { StationLabelContent, StationLabelContentProps } from './StationLabelContent'; | ||||||
|  |  | ||||||
|  | export const StationLabel = ({ station }: StationLabelContentProps) => ( | ||||||
|  |   <foreignObject className="track-station" {...station.labelOffset}> | ||||||
|  |     <StationLabelContent station={station} /> | ||||||
|  |   </foreignObject> | ||||||
|  | ); | ||||||
| @@ -0,0 +1,55 @@ | |||||||
|  | import { HTMLAttributes, ReactNode, useContext } from 'react'; | ||||||
|  |  | ||||||
|  | import { StationOnMap, TransportIcon, useMapWidgetContext } from '@mt/components'; | ||||||
|  | import { OnMapTextAlignment } from '@mt/common-types'; | ||||||
|  | import { LocalizationContext } from '@mt/i18n'; | ||||||
|  |  | ||||||
|  | export interface StationLabelContentProps extends HTMLAttributes<HTMLDivElement> { | ||||||
|  |   station: StationOnMap; | ||||||
|  |   children?: ReactNode; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type TextAlign = Lowercase<OnMapTextAlignment>; | ||||||
|  |  | ||||||
|  | export const StationLabelContent = ({ | ||||||
|  |   station, | ||||||
|  |   children, | ||||||
|  |   className = '', | ||||||
|  | }: StationLabelContentProps) => { | ||||||
|  |   const { locale } = useContext(LocalizationContext); | ||||||
|  |   const { rotateAngle } = useMapWidgetContext(); | ||||||
|  |  | ||||||
|  |   const { pointOnMap, labelAlignment, iconUrl, shortName, name, transferStationInfos } = station; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div | ||||||
|  |       id={`${pointOnMap.lat}:${pointOnMap.lon}`} | ||||||
|  |       className={`track-station__wrapper ${className}`} | ||||||
|  |       style={{ | ||||||
|  |         textAlign: labelAlignment as TextAlign, | ||||||
|  |         // Inverse angle to compensate map rotation | ||||||
|  |         transform: `rotate(${-rotateAngle}deg)`, | ||||||
|  |       }} | ||||||
|  |     > | ||||||
|  |       <div className="track-station__label"> | ||||||
|  |         {iconUrl && <img className="track-station__icon" src={iconUrl} />} | ||||||
|  |         {(shortName ?? name).ru} | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div className="track-station__transfers-wrapper"> | ||||||
|  |         {transferStationInfos.map((transfer) => ( | ||||||
|  |           <div className="track-station__label" key={transfer.name.ru}> | ||||||
|  |             <TransportIcon type={transfer.type} className="transport-icon" /> | ||||||
|  |             {transfer.name.ru} | ||||||
|  |           </div> | ||||||
|  |         ))} | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div className="track-station__label-locale"> | ||||||
|  |         {locale === 'zh' ? (shortName ?? name).zh : (shortName ?? name).en} | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {children} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,63 @@ | |||||||
|  | import { useState } from 'react'; | ||||||
|  | import Draggable, { DraggableData, DraggableEvent } from 'react-draggable'; | ||||||
|  | import { ButtonGroup, IconButton } from '@mui/material'; | ||||||
|  | import AlignHorizontalLeftRoundedIcon from '@mui/icons-material/AlignHorizontalLeftRounded'; | ||||||
|  | import AlignHorizontalCenterRoundedIcon from '@mui/icons-material/AlignHorizontalCenterRounded'; | ||||||
|  | import AlignHorizontalRightRoundedIcon from '@mui/icons-material/AlignHorizontalRightRounded'; | ||||||
|  |  | ||||||
|  | // TODO: resolve circular deps | ||||||
|  | import { OnMapOffset, OnMapTextAlignment } from '@mt/common-types'; | ||||||
|  | import { useMapWidgetContext } from '@mt/components'; | ||||||
|  | import { StationLabelContent, StationLabelContentProps } from './StationLabelContent'; | ||||||
|  |  | ||||||
|  | const CONTAINER_WIDTH = 1343; | ||||||
|  | const SVG_WIDTH = 500; | ||||||
|  |  | ||||||
|  | export const StationLabelEdit = ({ station }: StationLabelContentProps) => { | ||||||
|  |   const { onStationUpdate } = useMapWidgetContext(); | ||||||
|  |   const { id, labelOffset } = station; | ||||||
|  |  | ||||||
|  |   const [calculatedOffset] = useState<OnMapOffset>(labelOffset); | ||||||
|  |   const [dragStartPoint, setDragStartPoint] = useState<OnMapOffset>({ x: -1, y: -1 }); | ||||||
|  |  | ||||||
|  |   const onDragStart = () => setDragStartPoint(calculatedOffset); | ||||||
|  |  | ||||||
|  |   const onDragStop = (e: DraggableEvent, { lastX, lastY }: DraggableData) => { | ||||||
|  |     const labelOffset = { | ||||||
|  |       x: dragStartPoint.x + lastX, | ||||||
|  |       y: dragStartPoint.y + lastY, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     onStationUpdate(id, { labelOffset }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const setAlignment = (labelAlignment: OnMapTextAlignment): void => | ||||||
|  |     onStationUpdate(id, { labelAlignment }); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Draggable | ||||||
|  |       onStart={onDragStart} | ||||||
|  |       onStop={onDragStop} | ||||||
|  |       scale={CONTAINER_WIDTH / SVG_WIDTH} | ||||||
|  |       positionOffset={calculatedOffset} | ||||||
|  |     > | ||||||
|  |       <foreignObject className="track-station"> | ||||||
|  |         <StationLabelContent station={station} className="editable"> | ||||||
|  |           <ButtonGroup size="small" className="align-btns-group"> | ||||||
|  |             <IconButton size="small" onClick={() => setAlignment('LEFT')}> | ||||||
|  |               <AlignHorizontalLeftRoundedIcon /> | ||||||
|  |             </IconButton> | ||||||
|  |  | ||||||
|  |             <IconButton size="small" onClick={() => setAlignment('CENTER')}> | ||||||
|  |               <AlignHorizontalCenterRoundedIcon /> | ||||||
|  |             </IconButton> | ||||||
|  |  | ||||||
|  |             <IconButton size="small" onClick={() => setAlignment('RIGHT')}> | ||||||
|  |               <AlignHorizontalRightRoundedIcon /> | ||||||
|  |             </IconButton> | ||||||
|  |           </ButtonGroup> | ||||||
|  |         </StationLabelContent> | ||||||
|  |       </foreignObject> | ||||||
|  |     </Draggable> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,68 @@ | |||||||
|  | /* foreignObject */ | ||||||
|  | .track-station { | ||||||
|  |   overflow: visible; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .track-station__wrapper { | ||||||
|  |   transform-origin: left top; | ||||||
|  |   transform-box: fill-box; | ||||||
|  |   position: absolute; | ||||||
|  |   top: 0; | ||||||
|  |   left: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .track-station__wrapper.editable { | ||||||
|  |   cursor: move; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .track-station__label, | ||||||
|  | .track-station__label-locale { | ||||||
|  |   white-space: nowrap; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .track-station__label { | ||||||
|  |   color: #ffffff; | ||||||
|  |   font-size: 0.32rem; | ||||||
|  |   line-height: 1; | ||||||
|  |   letter-spacing: initial; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .track-station__label-locale { | ||||||
|  |   color: #cbcbcb; | ||||||
|  |   font-size: 0.25rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .track-station__transfers-wrapper:not(:empty) { | ||||||
|  |   margin: 1.5px 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .align-btns-group { | ||||||
|  |   position: absolute; | ||||||
|  |   left: 50%; | ||||||
|  |   transform: translate(-50%, 0); | ||||||
|  |   background-color: #ffffff; | ||||||
|  |   display: none !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .track-station__wrapper:hover .align-btns-group { | ||||||
|  |   display: flex !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .align-btns-group > button { | ||||||
|  |   width: 10px; | ||||||
|  |   height: 10px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .transport-icon, | ||||||
|  | .track-station__icon { | ||||||
|  |   display: inline-block; | ||||||
|  |   margin-right: 1px; | ||||||
|  |   vertical-align: middle; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .transport-icon, | ||||||
|  | .track-station__icon, | ||||||
|  | .track-station svg { | ||||||
|  |   width: 6px; | ||||||
|  |   height: 6px; | ||||||
|  | } | ||||||
| @@ -0,0 +1,57 @@ | |||||||
|  | import { Marker } from 'react-simple-maps'; | ||||||
|  | // TODO: resolve circular deps | ||||||
|  | import type { uuid } from '@mt/common-types'; | ||||||
|  |  | ||||||
|  | import { getMapPoint } from '../../utils'; | ||||||
|  |  | ||||||
|  | import './TrackStations.css'; | ||||||
|  | import { StationLabelEdit } from './StationLabelEdit'; | ||||||
|  | import { useMapWidgetContext } from '../../MapWidgetContext'; | ||||||
|  | import { StationLabel } from './StationLabel'; | ||||||
|  |  | ||||||
|  | const colors = { | ||||||
|  |   black: '#000000', | ||||||
|  |   red: '#ed1c24', | ||||||
|  |   grey: '#cccccc', | ||||||
|  |   yellow: '#fcd500', | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const TrackStations = () => { | ||||||
|  |   const { stations, currentStation, passedTrackIndex, isOnStation, isEditMode } = | ||||||
|  |     useMapWidgetContext(); | ||||||
|  |   const isTerminalStation = (index: number) => index === 0 || index === stations.length - 1; | ||||||
|  |  | ||||||
|  |   const getStationFill = (id: uuid, trackIndex: number, index: number): string => { | ||||||
|  |     if (isOnStation && currentStation?.id === id) { | ||||||
|  |       return colors.yellow; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (isTerminalStation(index)) { | ||||||
|  |       return colors.black; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return trackIndex <= passedTrackIndex ? colors.red : colors.grey; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const getStationStroke = (index: number) => { | ||||||
|  |     if (index === 0) return colors.red; | ||||||
|  |     if (index === stations.length - 1) return colors.grey; | ||||||
|  |     return colors.black; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       {stations.map((it, index) => ( | ||||||
|  |         <Marker key={it.id} coordinates={getMapPoint(it.pointOnMap)}> | ||||||
|  |           <circle | ||||||
|  |             fill={getStationFill(it.id, it.pointOnMap.trackIndex, index)} | ||||||
|  |             stroke={getStationStroke(index)} | ||||||
|  |             r={3.5} | ||||||
|  |             strokeWidth={isTerminalStation(index) ? 2 : 1.5} | ||||||
|  |           /> | ||||||
|  |           {isEditMode ? <StationLabelEdit station={it} /> : <StationLabel station={it} />} | ||||||
|  |         </Marker> | ||||||
|  |       ))} | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1 @@ | |||||||
|  | export * from './TrackStations'; | ||||||
| @@ -0,0 +1,36 @@ | |||||||
|  | .icon { | ||||||
|  |   width: 100%; | ||||||
|  |   height: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .iconContainer { | ||||||
|  |   position: relative; | ||||||
|  |   overflow: visible; | ||||||
|  |   border-radius: 50%; | ||||||
|  |   background: #e20613; | ||||||
|  |   padding: 3px; | ||||||
|  |   width: 32px; | ||||||
|  |   height: 32px; | ||||||
|  |   transform: translate(16px, 16px); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .flipped { | ||||||
|  |   transform: translate(-48px, -48px); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .iconContainer:after { | ||||||
|  |   content: ''; | ||||||
|  |   background: linear-gradient(135deg, #e20713, #00000000); | ||||||
|  |   clip-path: polygon(0 0, 50% 100%, 100% 50%); | ||||||
|  |   position: absolute; | ||||||
|  |   top: 0; | ||||||
|  |   left: 0; | ||||||
|  |   width: 100%; | ||||||
|  |   height: 100%; | ||||||
|  |   transform: translate(-50%, -50%); | ||||||
|  |   transform-origin: bottom right; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .flipped:after { | ||||||
|  |   transform: translate(-50%, -50%) rotate(180deg); | ||||||
|  | } | ||||||
| @@ -0,0 +1,48 @@ | |||||||
|  | import { useEffect, useState } from 'react'; | ||||||
|  | import { getIntersection, getIntersectionArea } from '../../utils'; | ||||||
|  | import { Marker, Point } from 'react-simple-maps'; | ||||||
|  | import { Coordinates } from '@mt/common-types'; | ||||||
|  | import cn from 'classnames'; | ||||||
|  | import styles from './TramMarker.module.css'; | ||||||
|  | import { Icons, useMapWidgetContext } from '@mt/components'; | ||||||
|  |  | ||||||
|  | interface TramMarkerProps { | ||||||
|  |   coordinates: Point; | ||||||
|  |   nextStopPoint: Coordinates; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const TramMarker = ({ coordinates, nextStopPoint }: TramMarkerProps) => { | ||||||
|  |   const [flipped, setFlipped] = useState(false); | ||||||
|  |  | ||||||
|  |   const { rotateAngle } = useMapWidgetContext(); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     const tramRect = document.getElementById('tram-marker')?.getBoundingClientRect(); | ||||||
|  |     const nextStopRect = document | ||||||
|  |       .getElementById(`${nextStopPoint.lat}:${nextStopPoint.lon}`) | ||||||
|  |       ?.getBoundingClientRect(); | ||||||
|  |  | ||||||
|  |     if (tramRect && nextStopRect) { | ||||||
|  |       // prettier-ignore | ||||||
|  |       const hasIntersection = getIntersection(tramRect, nextStopRect) !== null; | ||||||
|  |       const intersectionArea = getIntersectionArea(tramRect, nextStopRect); | ||||||
|  |  | ||||||
|  |       if (hasIntersection && intersectionArea > 150) { | ||||||
|  |         setFlipped((isFlipped) => !isFlipped); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     // eslint-disable-next-line react-hooks/exhaustive-deps | ||||||
|  |   }, [coordinates]); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Marker coordinates={coordinates} id="tram-marker"> | ||||||
|  |       <foreignObject className={cn(styles.iconContainer, { [styles.flipped]: flipped })}> | ||||||
|  |         <Icons.TramMarkerIcon | ||||||
|  |           className={`${styles.icon} g-transform-origin__center`} | ||||||
|  |           // Inverse angle to compensate map rotation | ||||||
|  |           style={{ transform: `rotate(${-rotateAngle}deg)` }} | ||||||
|  |         /> | ||||||
|  |       </foreignObject> | ||||||
|  |     </Marker> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1 @@ | |||||||
|  | export * from './TramMarker'; | ||||||
							
								
								
									
										5
									
								
								src/preview /lib/MapWidget/components/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/preview /lib/MapWidget/components/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | export * from './TramMarker'; | ||||||
|  | export * from './TrackLine'; | ||||||
|  | export * from './TrackStations'; | ||||||
|  | export * from './TrackAttractions'; | ||||||
|  | export * from './MapWidget'; | ||||||
							
								
								
									
										2
									
								
								src/preview /lib/MapWidget/hooks/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/preview /lib/MapWidget/hooks/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | |||||||
|  | export * from './usePassedTrackIndex'; | ||||||
|  | export * from './useNearStation'; | ||||||
							
								
								
									
										59
									
								
								src/preview /lib/MapWidget/hooks/useNearStation.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								src/preview /lib/MapWidget/hooks/useNearStation.ts
									
									
									
									
									
										Normal 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 }; | ||||||
|  | } | ||||||
							
								
								
									
										56
									
								
								src/preview /lib/MapWidget/hooks/usePassedTrackIndex.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/preview /lib/MapWidget/hooks/usePassedTrackIndex.ts
									
									
									
									
									
										Normal 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 }; | ||||||
|  | } | ||||||
							
								
								
									
										5
									
								
								src/preview /lib/MapWidget/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/preview /lib/MapWidget/index.ts
									
									
									
									
									
										Normal 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'; | ||||||
							
								
								
									
										49
									
								
								src/preview /lib/MapWidget/map-widget-context.interface.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/preview /lib/MapWidget/map-widget-context.interface.ts
									
									
									
									
									
										Normal 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; | ||||||
|  | } | ||||||
							
								
								
									
										10
									
								
								src/preview /lib/MapWidget/map-widget.constant.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/preview /lib/MapWidget/map-widget.constant.ts
									
									
									
									
									
										Normal 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, | ||||||
|  | }; | ||||||
							
								
								
									
										38
									
								
								src/preview /lib/MapWidget/map-widget.interface.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/preview /lib/MapWidget/map-widget.interface.ts
									
									
									
									
									
										Normal 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[]; | ||||||
|  | } | ||||||
							
								
								
									
										23
									
								
								src/preview /lib/MapWidget/utils/get-deviation.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/preview /lib/MapWidget/utils/get-deviation.ts
									
									
									
									
									
										Normal 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); | ||||||
|  | } | ||||||
							
								
								
									
										28
									
								
								src/preview /lib/MapWidget/utils/get-distance.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/preview /lib/MapWidget/utils/get-distance.ts
									
									
									
									
									
										Normal 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); | ||||||
|  | } | ||||||
							
								
								
									
										6
									
								
								src/preview /lib/MapWidget/utils/get-map-point.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/preview /lib/MapWidget/utils/get-map-point.ts
									
									
									
									
									
										Normal 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]; | ||||||
|  | } | ||||||
							
								
								
									
										4
									
								
								src/preview /lib/MapWidget/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/preview /lib/MapWidget/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | |||||||
|  | export * from './get-deviation'; | ||||||
|  | export * from './get-distance'; | ||||||
|  | export * from './get-map-point'; | ||||||
|  | export * from './intersections'; | ||||||
							
								
								
									
										30
									
								
								src/preview /lib/MapWidget/utils/intersections.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/preview /lib/MapWidget/utils/intersections.ts
									
									
									
									
									
										Normal 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; | ||||||
|  | } | ||||||
							
								
								
									
										67
									
								
								src/preview /lib/RoutInfoWidget/RouteInfoWidget.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/preview /lib/RoutInfoWidget/RouteInfoWidget.module.css
									
									
									
									
									
										Normal 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%); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										68
									
								
								src/preview /lib/RoutInfoWidget/RouteInfoWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								src/preview /lib/RoutInfoWidget/RouteInfoWidget.tsx
									
									
									
									
									
										Normal 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
		Reference in New Issue
	
	Block a user