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