Compare commits

..

36 Commits

Author SHA1 Message Date
9bf294e124 feat: Add service_name for article list
All checks were successful
release-tag / release-image (push) Successful in 2m23s
2025-05-26 17:24:04 +03:00
db5e9d9fc4 fix: Fix station create page
All checks were successful
release-tag / release-image (push) Successful in 2m16s
2025-05-25 23:16:33 +03:00
b1a4edc136 fix: Try to fix dockerfile
All checks were successful
release-tag / release-image (push) Successful in 2m18s
2025-05-25 20:20:09 +03:00
3110683c7d fix: Fix notification usage
Some checks failed
release-tag / release-image (push) Failing after 59s
2025-05-25 20:10:23 +03:00
16640cb116 fix: Fix shit Andrey code
Some checks failed
release-tag / release-image (push) Failing after 1m53s
2025-05-25 19:43:38 +03:00
28826123ec feat: Add snapshots page
All checks were successful
release-tag / release-image (push) Successful in 2m23s
2025-05-21 18:31:19 +03:00
bd19f1dc88 Add automated Docker image publishing workflow
All checks were successful
release-tag / release-image (push) Successful in 2m20s
Introduced a Gitea workflow to automate Docker image building and publishing upon push events. This setup includes QEMU configuration, DockerHub authentication, and multi-platform build support using Docker Buildx.
2025-05-20 20:20:42 +03:00
0fac04be0d fix: Fix Dockerfile for correct url response 2025-05-20 17:27:39 +03:00
8a443882b5 feat: Redesign route direction with media_order input 2025-05-20 16:59:57 +03:00
7c363f1730 underline 2025-05-20 00:09:57 +03:00
d9bbe4f234 carriers 2025-05-19 22:20:26 +03:00
6eaa94778b changed according to the third tour 2025-05-19 21:53:34 +03:00
34423b73a3 changes for 15.05 2025-05-17 05:55:57 +03:00
ab1fd6b22a last changes, possibly 2025-05-15 04:32:23 +03:00
042b53e6a4 last changes 2025-05-14 14:42:45 +03:00
177653d84a remaining fixes 2025-05-07 00:02:24 +03:00
fba2fb0f5c more fixes 2025-05-06 17:56:05 +03:00
86947d6332 moved city into other tab 2025-05-05 03:40:08 +03:00
7c920eb81e fix fix fix fix 2025-05-05 02:54:46 +03:00
4b20c94b70 map3 2025-05-05 01:19:59 +03:00
fb16891de3 Merge branch 'update' into preview 2025-05-04 22:41:12 +03:00
6454af90d3 map 2 2025-05-04 22:40:44 +03:00
275eef597b preview 2025-05-03 01:17:37 +03:00
65532f7074 rewrite code with edit article in sight page 2025-05-01 00:10:50 +03:00
dc483d62de added old preview components 2025-04-29 22:10:50 +03:00
03829aacc6 station edit in the route edit page 2025-04-29 21:16:53 +03:00
a1a2264758 sight edit page update 2025-04-27 16:18:52 +03:00
0d325a3aa6 added more types for media 2025-04-27 11:27:22 +03:00
abd054b8d4 delete useless querykey 2025-04-25 05:54:41 +03:00
9927c0afd6 sight edit update 2025-04-25 05:23:07 +03:00
463c593a0e route edit fixed with drag and drop tool 2025-04-21 15:34:11 +03:00
9e34a71e14 fetching data from api for route preview 2025-04-20 10:55:12 +03:00
029a2de97e added route preview with updated dnd 2025-04-16 13:51:44 +03:00
4dd149f2af added route preview 2025-04-15 21:12:43 +03:00
b6449b02c0 update route preview 2025-04-14 01:03:58 +03:00
607012bd47 abstract urls 2025-04-11 19:24:45 +03:00
35 changed files with 1185 additions and 1867 deletions

View File

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" type="image/png" href="/favicon_ship.png" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta
@ -19,7 +19,9 @@
name="twitter:image" name="twitter:image"
content="https://refine.dev/img/refine_social.png" content="https://refine.dev/img/refine_social.png"
/> />
<title>Белые ночи</title> <title>
Белые ночи
</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -70,7 +70,6 @@ type LinkedItemsProps<T> = {
disableCreation?: boolean; disableCreation?: boolean;
updatedLinkedItems?: T[]; updatedLinkedItems?: T[];
refresh?: number; refresh?: number;
cityId?: number;
}; };
const reorder = (list: any[], startIndex: number, endIndex: number) => { const reorder = (list: any[], startIndex: number, endIndex: number) => {
@ -132,7 +131,6 @@ export const LinkedItemsContents = <
disableCreation = false, disableCreation = false,
updatedLinkedItems, updatedLinkedItems,
refresh, refresh,
cityId,
}: LinkedItemsProps<T>) => { }: LinkedItemsProps<T>) => {
const { language } = languageStore; const { language } = languageStore;
const { setArticleModalOpenAction, setArticleIdAction } = articleStore; const { setArticleModalOpenAction, setArticleIdAction } = articleStore;
@ -218,7 +216,7 @@ export const LinkedItemsContents = <
useEffect(() => { useEffect(() => {
if (type === "edit") { if (type === "edit") {
axiosInstance axiosInstance
.get(`${import.meta.env.VITE_KRBL_API}/${childResource}/`, {}) .get(`${import.meta.env.VITE_KRBL_API}/${childResource}/`)
.then((response) => { .then((response) => {
setItems(response?.data || []); setItems(response?.data || []);
setIsLoading(false); setIsLoading(false);
@ -447,7 +445,7 @@ export const LinkedItemsContents = <
availableItems?.find((item) => item.id === selectedItemId) || null availableItems?.find((item) => item.id === selectedItemId) || null
} }
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)} onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
options={availableItems.filter((item) => item.city_id == cityId)} options={availableItems}
getOptionLabel={(item) => String(item[fields[0].data])} getOptionLabel={(item) => String(item[fields[0].data])}
renderInput={(params) => ( renderInput={(params) => (
<TextField {...params} label={`Выберите ${title}`} fullWidth /> <TextField {...params} label={`Выберите ${title}`} fullWidth />
@ -458,7 +456,6 @@ export const LinkedItemsContents = <
.toLowerCase() .toLowerCase()
.split(" ") .split(" ")
.filter((word) => word.length > 0); .filter((word) => word.length > 0);
return options.filter((option) => { return options.filter((option) => {
const optionWords = String(option[fields[0].data]) const optionWords = String(option[fields[0].data])
.toLowerCase() .toLowerCase()

View File

@ -143,17 +143,8 @@ export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = observer(
justifyContent="flex-end" justifyContent="flex-end"
alignItems="center" alignItems="center"
spacing={2} spacing={2}
color="white"
sx={{
"& .MuiSelect-select": {
color: "white",
},
}}
> >
<FormControl <FormControl variant="standard" sx={{ width: "min-content" }}>
variant="standard"
sx={{ width: "min-content", color: "white" }}
>
{city_id && cities && ( {city_id && cities && (
<Select <Select
defaultValue={city_id} defaultValue={city_id}

View File

@ -121,7 +121,7 @@ export const StationEditModal = observer(() => {
> >
<Box sx={style}> <Box sx={style}>
<Edit <Edit
title={<Typography variant="h5">Редактирование остановки</Typography>} title={<Typography variant="h5">Редактирование станции</Typography>}
saveButtonProps={saveButtonProps} saveButtonProps={saveButtonProps}
> >
<Box <Box

View File

@ -15,9 +15,9 @@ export function MediaView({ media }: Readonly<{ media?: MediaData }>) {
<Box <Box
sx={{ sx={{
maxHeight: "300px", maxHeight: "300px",
width: "80%", width: "100%",
height: "100%", height: "100%",
maxWidth: "600px", maxWidth: "300px",
display: "flex", display: "flex",
flexGrow: 1, flexGrow: 1,
justifyContent: "center", justifyContent: "center",

View File

@ -1,15 +1,11 @@
import { Logo } from "@/icons/Logo"; import {ProjectIcon} from './Icons'
export default function SidebarTitle({ collapsed }: { collapsed: boolean }) { export default function SidebarTitle({collapsed}: {collapsed: boolean}) {
return ( return (
<div <div style={{display: 'flex', alignItems: 'center', whiteSpace: 'nowrap'}}>
style={{ display: "flex", alignItems: "center", whiteSpace: "nowrap" }} <ProjectIcon style={{color: '#7f6b58'}} />
>
<Logo width={40} height={40} />
{!collapsed && ( {!collapsed && <span style={{marginLeft: 8, fontWeight: 'bold'}}>Белые ночи</span>}
<span style={{ marginLeft: 8, fontWeight: "bold" }}>Белые ночи</span>
)}
</div> </div>
); )
} }

View File

@ -14,12 +14,12 @@
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
margin: 0; margin: 0;
width: 32px; width: 35px;
height: 32px; height: 35px;
color: rgba(79, 138, 95, 1); color: #544044;
border-radius: 10%; border-radius: 10%;
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.backup-button:hover { .backup-button:hover {
background-color: rgba(79, 138, 95, 0.05); background-color: rgba(84, 64, 68, 0.5);
} }

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.5 KiB

File diff suppressed because one or more lines are too long

View File

@ -94,9 +94,9 @@
}, },
"station": { "station": {
"titles": { "titles": {
"create": "Создать остановку", "create": "Создать станцию",
"edit": "Редактировать остановку", "edit": "Редактировать станцию",
"show": "Показать остановку" "show": "Показать станцию"
} }
}, },
"snapshots": { "snapshots": {

View File

@ -3,63 +3,19 @@ import { Create, useAutocomplete } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form"; import { useForm } from "@refinedev/react-hook-form";
import { Controller } from "react-hook-form"; import { Controller } from "react-hook-form";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { META_LANGUAGE } from "../../store/LanguageStore"; import { languageStore, META_LANGUAGE } from "../../store/LanguageStore";
import { LanguageSwitch } from "@/components/LanguageSwitch";
import { useEffect, useState } from "react";
import { EVERY_LANGUAGE, Languages, languageStore } from "@stores";
export const CarrierCreate = observer(() => { export const CarrierCreate = observer(() => {
const { language, setLanguageAction } = languageStore; const { language } = languageStore;
const { const {
saveButtonProps, saveButtonProps,
refineCore: { formLoading }, refineCore: { formLoading },
register, register,
control, control,
setValue,
watch,
formState: { errors }, formState: { errors },
} = useForm({ } = useForm({
refineCoreProps: META_LANGUAGE(language), refineCoreProps: META_LANGUAGE(language)
}); });
const [carrierData, setCarrierData] = useState({
full_name: EVERY_LANGUAGE(""),
short_name: EVERY_LANGUAGE(""),
slogan: EVERY_LANGUAGE(""),
});
useEffect(() => {
setValue("full_name", carrierData.full_name[language]);
setValue("short_name", carrierData.short_name[language]);
setValue("slogan", carrierData.slogan[language]);
}, [carrierData, language, setValue]);
function updateTranslations(update: boolean = true) {
const newCarrierData = {
...carrierData,
full_name: {
...carrierData.full_name,
[language]: watch("full_name") ?? "",
},
short_name: {
...carrierData.short_name,
[language]: watch("short_name") ?? "",
},
slogan: {
...carrierData.slogan,
[language]: watch("slogan") ?? "",
},
};
if (update) setCarrierData(newCarrierData);
return newCarrierData;
}
const handleLanguageChange = (lang: Languages) => {
updateTranslations();
setLanguageAction(lang);
};
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({ const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
resource: "city", resource: "city",
onSearch: (value) => [ onSearch: (value) => [
@ -89,7 +45,6 @@ export const CarrierCreate = observer(() => {
sx={{ display: "flex", flexDirection: "column" }} sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off" autoComplete="off"
> >
<LanguageSwitch action={handleLanguageChange} />
<Controller <Controller
control={control} control={control}
name="city_id" name="city_id"
@ -140,7 +95,7 @@ export const CarrierCreate = observer(() => {
helperText={(errors as any)?.full_name?.message} helperText={(errors as any)?.full_name?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} slotProps={{inputLabel: {shrink: true}}}
type="text" type="text"
label={"Полное имя *"} label={"Полное имя *"}
name="full_name" name="full_name"
@ -154,13 +109,16 @@ export const CarrierCreate = observer(() => {
helperText={(errors as any)?.short_name?.message} helperText={(errors as any)?.short_name?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} slotProps={{inputLabel: {shrink: true}}}
type="text" type="text"
label={"Короткое имя"} label={"Короткое имя"}
name="short_name" name="short_name"
/> />
<Box component="form" sx={{ display: "flex" }} autoComplete="off"> <Box component="form"
sx={{ display: "flex" }}
autoComplete="off"
>
<TextField <TextField
{...register("main_color", { {...register("main_color", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
@ -169,7 +127,7 @@ export const CarrierCreate = observer(() => {
helperText={(errors as any)?.main_color?.message} helperText={(errors as any)?.main_color?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} slotProps={{inputLabel: {shrink: true}}}
type="color" type="color"
label={"Основной цвет"} label={"Основной цвет"}
name="main_color" name="main_color"
@ -191,7 +149,7 @@ export const CarrierCreate = observer(() => {
helperText={(errors as any)?.left_color?.message} helperText={(errors as any)?.left_color?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} slotProps={{inputLabel: {shrink: true}}}
type="color" type="color"
label={"Цвет левого виджета"} label={"Цвет левого виджета"}
name="left_color" name="left_color"
@ -214,7 +172,7 @@ export const CarrierCreate = observer(() => {
helperText={(errors as any)?.right_color?.message} helperText={(errors as any)?.right_color?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} slotProps={{inputLabel: {shrink: true}}}
type="color" type="color"
label={"Цвет правого виджета"} label={"Цвет правого виджета"}
name="right_color" name="right_color"
@ -237,7 +195,7 @@ export const CarrierCreate = observer(() => {
helperText={(errors as any)?.slogan?.message} helperText={(errors as any)?.slogan?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} slotProps={{inputLabel: {shrink: true}}}
type="text" type="text"
label={"Слоган"} label={"Слоган"}
name="slogan" name="slogan"

View File

@ -1,224 +1,69 @@
import { Autocomplete, Box, TextField } from "@mui/material"; import {Autocomplete, Box, TextField} from '@mui/material'
import { Create, useAutocomplete } from "@refinedev/mui"; import {Create, useAutocomplete} from '@refinedev/mui'
import { useForm } from "@refinedev/react-hook-form"; import {useForm} from '@refinedev/react-hook-form'
import { Controller } from "react-hook-form"; import {Controller} from 'react-hook-form'
import { useEffect, useState } from "react";
import { EVERY_LANGUAGE, Languages, languageStore } from "@stores";
import { LanguageSwitch } from "@/components/LanguageSwitch";
import { axiosInstanceForGet } from "@/providers";
import { useNavigate } from "react-router";
import { useNotification } from "@refinedev/core";
export const CityCreate = () => { export const CityCreate = () => {
const { language, setLanguageAction } = languageStore;
const navigate = useNavigate();
const notification = useNotification();
// State to manage city name translations across all supported languages.
// Initializes with empty strings for each language.
const [allLanguageNames, setAllLanguageNames] = useState<
Record<Languages, string>
>(EVERY_LANGUAGE(""));
const { const {
saveButtonProps, saveButtonProps,
refineCore: { formLoading }, refineCore: {formLoading},
register, register,
control, control,
setValue, formState: {errors},
watch, } = useForm({})
handleSubmit,
formState: { errors },
} = useForm<{
name: string;
country_code: string;
arms: string;
}>({});
// Keeps the 'name' input field synchronized with the currently active language's translation. const {autocompleteProps: countryAutocompleteProps} = useAutocomplete({
// Updates whenever the active language or the `allLanguageNames` state changes. resource: 'country',
useEffect(() => { })
setValue("name", allLanguageNames[language]);
}, [language, allLanguageNames, setValue]);
// Captures the current value of the 'name' TextField and updates the `allLanguageNames` state. const {autocompleteProps: mediaAutocompleteProps} = useAutocomplete({
// This is vital for preserving user input when switching languages or before form submission. resource: 'media',
const updateCurrentLanguageName = () => {
const currentNameValue = watch("name");
setAllLanguageNames((prev) => ({
...prev,
[language]: currentNameValue || "",
}));
};
// Handles language changes. It first saves the current input, then updates the active language.
const handleLanguageChange = (lang: Languages) => {
updateCurrentLanguageName();
setLanguageAction(lang);
};
// Autocomplete hooks for selecting a country and city arms (media).
const { autocompleteProps: countryAutocompleteProps } = useAutocomplete({
resource: "country",
});
const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({
resource: "media",
onSearch: (value) => [ onSearch: (value) => [
{ {
field: "media_name", field: 'media_name',
operator: "contains", operator: 'contains',
value, value,
}, },
], ],
}); })
// --- Form Submission Logic ---
// Handles the form submission. It saves the current language's input,
// validates the Russian name, and then sends requests to create/update city data
// across different languages.
const onFinish = async (data: {
name: string;
country_code: string;
arms: string;
}) => {
updateCurrentLanguageName();
const finalNames = {
...allLanguageNames,
[language]: data.name,
};
try {
if (!finalNames.ru) {
console.error("Russian name is required for initial city creation.");
if (notification && typeof notification.open === "function") {
notification.open({
message: "Ошибка",
description: "Русское название города обязательно для создания.",
type: "error",
});
}
return;
}
console.log("Submitting with names:", finalNames);
// Create the city with the Russian name first.
const ruResponse = await axiosInstanceForGet("ru").post("/city", {
name: finalNames.ru,
country_code: data.country_code,
arms: data.arms,
});
const id = ruResponse.data.id;
// Update the city with English and Chinese names if available.
if (finalNames.en) {
await axiosInstanceForGet("en").patch(`/city/${id}`, {
name: finalNames.en,
country_code: data.country_code,
arms: data.arms,
});
}
if (finalNames.zh) {
await axiosInstanceForGet("zh").patch(`/city/${id}`, {
name: finalNames.zh,
country_code: data.country_code,
arms: data.arms,
});
}
console.log("City created/updated successfully!");
if (notification && typeof notification.open === "function") {
notification.open({
message: "Город успешно создан",
type: "success",
});
}
navigate("/city", { replace: true });
} catch (error) {
console.error("Error creating/updating city:", error);
}
};
return ( return (
<Create <Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
isLoading={formLoading} <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
saveButtonProps={{
...saveButtonProps,
disabled: saveButtonProps.disabled,
onClick: handleSubmit(onFinish as any),
}}
>
<Box
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<LanguageSwitch action={handleLanguageChange} />
<Controller <Controller
control={control} control={control}
name="country_code" name="country_code"
rules={{ required: "Это поле является обязательным" }} rules={{required: 'Это поле является обязательным'}}
defaultValue={null} defaultValue={null}
render={({ field }) => ( render={({field}) => (
<Autocomplete <Autocomplete
{...countryAutocompleteProps} {...countryAutocompleteProps}
value={ value={countryAutocompleteProps.options.find((option) => option.code === field.value) || null}
countryAutocompleteProps.options.find( onChange={(_, value) => {
(option: { code: string; name: string; id: string }) => field.onChange(value?.code || '')
option.code === field.value
) || null
}
onChange={(
_,
value: { code: string; name: string; id: string } | null
) => {
field.onChange(value?.code || "");
}} }}
getOptionLabel={(item: { getOptionLabel={(item) => {
code: string; return item ? item.name : ''
name: string;
id: string;
}) => {
return item ? item.name : "";
}} }}
isOptionEqualToValue={( isOptionEqualToValue={(option, value) => {
option: { code: string; name: string; id: string }, return option.id === value?.id
value: { code: string; name: string; id: string }
) => {
return option.id === value?.id;
}} }}
renderInput={(params) => ( renderInput={(params) => <TextField {...params} label="Выберите страну" margin="normal" variant="outlined" error={!!errors.country_code} helperText={(errors as any)?.country_code?.message} required />}
<TextField
{...params}
label="Выберите страну"
margin="normal"
variant="outlined"
error={!!errors.country_code}
required
/>
)}
/> />
)} )}
/> />
<TextField <TextField
{...register("name", { {...register('name', {
required: "Это поле является обязательным", required: 'Это поле является обязательным',
onBlur: updateCurrentLanguageName,
})} })}
error={!!errors.name} error={!!(errors as any)?.name}
helperText={(errors as any)?.name?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{ shrink: true }} InputLabelProps={{shrink: true}}
type="text" type="text"
label={"Название *"} label={'Название *'}
name="name" name="name"
/> />
@ -226,53 +71,27 @@ export const CityCreate = () => {
control={control} control={control}
name="arms" name="arms"
defaultValue={null} defaultValue={null}
render={({ field }) => ( render={({field}) => (
<Autocomplete <Autocomplete
{...mediaAutocompleteProps} {...mediaAutocompleteProps}
value={ value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null}
mediaAutocompleteProps.options.find( onChange={(_, value) => {
(option: { id: string; media_name: string }) => field.onChange(value?.id || '')
option.id === field.value
) || null
}
onChange={(
_,
value: { id: string; media_name: string } | null
) => {
field.onChange(value?.id || "");
}} }}
getOptionLabel={(item: { id: string; media_name: string }) => { getOptionLabel={(item) => {
return item ? item.media_name : ""; return item ? item.media_name : ''
}} }}
isOptionEqualToValue={( isOptionEqualToValue={(option, value) => {
option: { id: string; media_name: string }, return option.id === value?.id
value: { id: string; media_name: string }
) => {
return option.id === value?.id;
}} }}
filterOptions={( filterOptions={(options, {inputValue}) => {
options: { id: string; media_name: string }[], return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase()))
{ inputValue }
) => {
return options.filter((option) =>
option.media_name
.toLowerCase()
.includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => ( renderInput={(params) => <TextField {...params} label="Выберите герб" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} />}
<TextField
{...params}
label="Выберите герб"
margin="normal"
variant="outlined"
error={!!errors.arms}
/>
)}
/> />
)} )}
/> />
</Box> </Box>
</Create> </Create>
); )
}; }

View File

@ -1,20 +1,13 @@
import { AuthPage, ThemedTitleV2 } from "@refinedev/mui"; import {AuthPage, ThemedTitleV2} from '@refinedev/mui'
import {ProjectIcon} from '../../components/ui/Icons'
import { Logo } from "@/icons/Logo";
export const Login = () => { export const Login = () => {
return ( return (
<AuthPage <AuthPage
type="login" type="login"
title={ title={<ThemedTitleV2 collapsed={false} text="Белые Ночи" icon={<ProjectIcon style={{color: '#7f6b58'}} />} />}
<ThemedTitleV2
collapsed={false}
text="Белые Ночи"
icon={<Logo width={24} height={24} />}
/>
}
forgotPasswordLink={false} forgotPasswordLink={false}
registerLink={false} // only admin can add users registerLink={false} // only admin can add users
/> />
); )
}; }

View File

@ -100,6 +100,7 @@ export const MediaList = observer(() => {
columns={columns} columns={columns}
localeText={localeText} localeText={localeText}
getRowId={(row: any) => row.id} getRowId={(row: any) => row.id}
languageEnabled
/> />
</List> </List>
); );

View File

@ -1,230 +1,179 @@
import { FederatedMouseEvent, FederatedWheelEvent } from "pixi.js"; import { FederatedMouseEvent, FederatedWheelEvent } from "pixi.js";
import { Component, ReactNode, useEffect, useState, useRef } from "react"; import { Component, ReactNode, useEffect, useState } from "react";
import { useTransform } from "./TransformContext"; import { useTransform } from "./TransformContext";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
import { SCALE_FACTOR } from "./Constants"; import { SCALE_FACTOR } from "./Constants";
import { useApplication } from "@pixi/react"; import { useApplication } from "@pixi/react";
class ErrorBoundary extends Component< class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
{ children: ReactNode }, state = { hasError: false };
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() { static getDerivedStateFromError() {
return { hasError: true }; return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Error caught:", error, info);
}
render() {
return this.state.hasError ? <p>Whoopsie Daisy!</p> : this.props.children;
}
} }
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Error caught:", error, info);
}
render() { export function InfiniteCanvas({children} : Readonly<{children?: ReactNode}>) {
return this.state.hasError ? <p>Whoopsie Daisy!</p> : this.props.children; const { position, setPosition, scale, setScale, rotation, setRotation, setScreenCenter, screenCenter } = useTransform();
} const { routeData, originalRouteData } = useMapData();
}
const applicationRef = useApplication();
export function InfiniteCanvas({
children, const [isDragging, setIsDragging] = useState(false);
}: Readonly<{ children?: ReactNode }>) { const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
const { const [startRotation, setStartRotation] = useState(0);
position, const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
setPosition,
scale, useEffect(() => {
setScale, const canvas = applicationRef?.app.canvas;
rotation, if (!canvas) return;
setRotation, const canvasRect = canvas.getBoundingClientRect();
setScreenCenter, const canvasLeft = canvasRect?.left ?? 0;
screenCenter, const canvasTop = canvasRect?.top ?? 0;
} = useTransform(); const centerX = window.innerWidth / 2 - canvasLeft;
const { routeData, originalRouteData } = useMapData(); const centerY = window.innerHeight / 2 - canvasTop;
setScreenCenter({x: centerX, y: centerY});
const applicationRef = useApplication(); }, [applicationRef?.app.canvas, window.innerWidth, window.innerHeight]);
const [isDragging, setIsDragging] = useState(false); const handlePointerDown = (e: FederatedMouseEvent) => {
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 }); setIsDragging(true);
const [startRotation, setStartRotation] = useState(0); setStartPosition({
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); x: position.x,
y: position.y
// Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута });
const [isUserInteracting, setIsUserInteracting] = useState(false); setStartMousePosition({
x: e.globalX,
// Реф для отслеживания последнего значения originalRouteData?.rotate y: e.globalY
const lastOriginalRotation = useRef<number | undefined>(undefined); });
setStartRotation(rotation);
useEffect(() => { e.stopPropagation();
const canvas = applicationRef?.app.canvas; };
if (!canvas) return;
const canvasRect = canvas.getBoundingClientRect(); useEffect(() => {
const canvasLeft = canvasRect.left; setRotation((originalRouteData?.rotate ?? 0) * Math.PI / 180);
const canvasTop = canvasRect.top; }, [originalRouteData?.rotate]);
const centerX = window.innerWidth / 2 - canvasLeft; // Get canvas element and its dimensions/position
const centerY = window.innerHeight / 2 - canvasTop; const handlePointerMove = (e: FederatedMouseEvent) => {
setScreenCenter({ x: centerX, y: centerY }); if (!isDragging) return;
}, [applicationRef?.app.canvas, setScreenCenter]);
if (e.shiftKey) {
const handlePointerDown = (e: FederatedMouseEvent) => { const center = screenCenter ?? {x: 0, y: 0};
setIsDragging(true); const startAngle = Math.atan2(startMousePosition.y - center.y, startMousePosition.x - center.x);
setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя const currentAngle = Math.atan2(e.globalY - center.y, e.globalX - center.x);
setStartPosition({
x: position.x, // Calculate rotation difference in radians
y: position.y, const rotationDiff = currentAngle - startAngle;
});
setStartMousePosition({ // Update rotation
x: e.globalX, setRotation(startRotation + rotationDiff);
y: e.globalY,
}); const cosDelta = Math.cos(rotationDiff);
setStartRotation(rotation); const sinDelta = Math.sin(rotationDiff);
e.stopPropagation();
}; setPosition({
x: center.x * (1 - cosDelta) + startPosition.x * cosDelta + (center.y - startPosition.y) * sinDelta,
// Устанавливаем rotation только при изменении originalRouteData и отсутствии взаимодействия пользователя y: center.y * (1 - cosDelta) + startPosition.y * cosDelta + (startPosition.x - center.x) * sinDelta
useEffect(() => { });
const newRotation = originalRouteData?.rotate ?? 0;
} else {
// Обновляем rotation только если: setRotation(startRotation);
// 1. Пользователь не взаимодействует с канвасом setPosition({
// 2. Значение действительно изменилось x: startPosition.x - startMousePosition.x + e.globalX,
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) { y: startPosition.y - startMousePosition.y + e.globalY
setRotation((newRotation * Math.PI) / 180); });
lastOriginalRotation.current = newRotation; }
} e.stopPropagation();
}, [originalRouteData?.rotate, isUserInteracting, setRotation]); };
const handlePointerMove = (e: FederatedMouseEvent) => { // Handle mouse up
if (!isDragging) return; const handlePointerUp = (e: FederatedMouseEvent) => {
setIsDragging(false);
if (e.shiftKey) { e.stopPropagation();
const center = screenCenter ?? { x: 0, y: 0 }; };
const startAngle = Math.atan2( // Handle mouse wheel for zooming
startMousePosition.y - center.y, const handleWheel = (e: FederatedWheelEvent) => {
startMousePosition.x - center.x e.stopPropagation();
);
const currentAngle = Math.atan2( // Get mouse position relative to canvas
e.globalY - center.y, const mouseX = e.globalX - position.x;
e.globalX - center.x const mouseY = e.globalY - position.y;
);
// Calculate new scale
// Calculate rotation difference in radians const scaleMin = (routeData?.scale_min ?? 10)/SCALE_FACTOR;
const rotationDiff = currentAngle - startAngle; const scaleMax = (routeData?.scale_max ?? 20)/SCALE_FACTOR;
// Update rotation let zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in
setRotation(startRotation + rotationDiff); //const newScale = scale * zoomFactor;
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
const cosDelta = Math.cos(rotationDiff); zoomFactor = newScale / scale;
const sinDelta = Math.sin(rotationDiff);
if (scale === newScale) {
setPosition({ return;
x: }
center.x * (1 - cosDelta) +
startPosition.x * cosDelta + // Update position to zoom towards mouse cursor
(center.y - startPosition.y) * sinDelta, setPosition({
y: x: position.x + mouseX * (1 - zoomFactor),
center.y * (1 - cosDelta) + y: position.y + mouseY * (1 - zoomFactor)
startPosition.y * cosDelta + });
(startPosition.x - center.x) * sinDelta,
}); setScale(newScale);
} else { };
setRotation(startRotation);
setPosition({ useEffect(() => {
x: startPosition.x - startMousePosition.x + e.globalX, applicationRef?.app.render();
y: startPosition.y - startMousePosition.y + e.globalY, console.log(position, scale, rotation);
}); }, [position, scale, rotation]);
}
e.stopPropagation();
}; return (
<ErrorBoundary>
const handlePointerUp = (e: FederatedMouseEvent) => { {applicationRef?.app && (
setIsDragging(false); <pixiGraphics
// Сбрасываем флаг взаимодействия через небольшую задержку draw={(g) => {
// чтобы избежать немедленного срабатывания useEffect const canvas = applicationRef.app.canvas;
setTimeout(() => { g.clear();
setIsUserInteracting(false); g.rect(0, 0, canvas?.width ?? 0, canvas?.height ?? 0);
}, 100); g.fill("#111");
e.stopPropagation(); }}
}; eventMode={'static'}
interactive
const handleWheel = (e: FederatedWheelEvent) => { onPointerDown={handlePointerDown}
e.stopPropagation(); onGlobalPointerMove={handlePointerMove}
setIsUserInteracting(true); // Устанавливаем флаг при зуме onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
// Get mouse position relative to canvas onWheel={handleWheel}
const mouseX = e.globalX - position.x; />
const mouseY = e.globalY - position.y; )}
<pixiContainer
// Calculate new scale x={position.x}
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR; y={position.y}
const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR; scale={scale}
rotation={rotation}
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in >
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor)); {children}
const actualZoomFactor = newScale / scale; </pixiContainer>
{/* Show center of the screen.
if (scale === newScale) { <pixiGraphics
// Сбрасываем флаг, если зум не изменился eventMode="none"
setTimeout(() => {
setIsUserInteracting(false); draw={(g) => {
}, 100); g.clear();
return; const center = screenCenter ?? {x: 0, y: 0};
} g.circle(center.x, center.y, 1);
g.fill("#fff");
// Update position to zoom towards mouse cursor }}
setPosition({ /> */}
x: position.x + mouseX * (1 - actualZoomFactor), </ErrorBoundary>
y: position.y + mouseY * (1 - actualZoomFactor), );
});
setScale(newScale);
// Сбрасываем флаг взаимодействия через задержку
setTimeout(() => {
setIsUserInteracting(false);
}, 100);
};
useEffect(() => {
applicationRef?.app.render();
console.log(position, scale, rotation);
}, [position, scale, rotation]);
return (
<ErrorBoundary>
{applicationRef?.app && (
<pixiGraphics
draw={(g) => {
const canvas = applicationRef.app.canvas;
g.clear();
g.rect(0, 0, canvas?.width ?? 0, canvas?.height ?? 0);
g.fill("#111");
}}
eventMode={"static"}
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
onWheel={handleWheel}
/>
)}
<pixiContainer
x={position.x}
y={position.y}
scale={scale}
rotation={rotation}
>
{children}
</pixiContainer>
{/* Show center of the screen.
<pixiGraphics
eventMode="none"
draw={(g) => {
g.clear();
const center = screenCenter ?? {x: 0, y: 0};
g.circle(center.x, center.y, 1);
g.fill("#fff");
}}
/> */}
</ErrorBoundary>
);
} }

View File

@ -1,89 +1,33 @@
import { Stack, Typography, Button } from "@mui/material"; import { Stack, Typography, Button } from "@mui/material";
import { useNavigate, useNavigationType } from "react-router";
export function LeftSidebar() { export function LeftSidebar() {
const navigate = useNavigate(); return (
const navigationType = useNavigationType(); // PUSH, POP, REPLACE <Stack direction="column" width="300px" p={2} bgcolor="primary.main">
<Stack direction="column" alignItems="center" justifyContent="center" my={10}>
<img src={"/Emblem.svg"} alt="logo" width={100} height={100} />
<Typography sx={{ mb: 2 }} textAlign="center">
При поддержке Правительства
Санкт-Петербурга
</Typography>
</Stack>
const handleBack = () => {
if (navigationType === "PUSH") {
navigate(-1);
} else {
navigate("/route");
}
};
return ( <Stack direction="column" alignItems="center" justifyContent="center" my={10} spacing={2}>
<Stack direction="column" width="300px" p={2} bgcolor="primary.main"> <Button variant="outlined" color="warning" fullWidth>
<button Достопримечательности
onClick={handleBack} </Button>
type="button" <Button variant="outlined" color="warning" fullWidth>
style={{ Остановки
display: "flex", </Button>
justifyContent: "center", </Stack>
alignItems: "center",
gap: 10,
color: "#fff",
backgroundColor: "#222",
borderRadius: 10,
width: "100%",
border: "none",
cursor: "pointer",
}}
>
<p>Назад</p>
</button>
<Stack <Stack direction="column" alignItems="center" justifyContent="center" my={10}>
direction="column" <img src={"/GET.png"} alt="logo" width="80%" style={{margin: "0 auto"}}/>
alignItems="center" </Stack>
justifyContent="center"
my={10}
>
<img src={"/Emblem.svg"} alt="logo" width={100} height={100} />
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center">
При поддержке Правительства Санкт-Петербурга
</Typography>
</Stack>
<Stack <Typography variant="h6" textAlign="center" mt="auto">#ВсемПоПути</Typography>
direction="column"
alignItems="center"
justifyContent="center"
my={10}
spacing={2}
>
<Button variant="outlined" color="warning" fullWidth>
Достопримечательности
</Button>
<Button variant="outlined" color="warning" fullWidth>
Остановки
</Button>
</Stack>
<Stack
direction="column"
alignItems="center"
justifyContent="center"
my={10}
>
<img
src={"/GET.png"}
alt="logo"
width="80%"
style={{ margin: "0 auto" }}
/>
</Stack>
<Typography </Stack>
variant="h6" );
textAlign="center"
mt="auto"
sx={{ color: "#fff" }}
>
#ВсемПоПути
</Typography>
</Stack>
);
} }

View File

@ -1,4 +1,4 @@
import { useApiUrl } from "@refinedev/core"; import { useCustom, useApiUrl } from "@refinedev/core";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { import {
createContext, createContext,
@ -16,16 +16,13 @@ import {
StationPatchData, StationPatchData,
} from "./types"; } from "./types";
import { axiosInstance } from "../../providers/data"; import { axiosInstance } from "../../providers/data";
import { languageStore, META_LANGUAGE } from "@/store/LanguageStore";
import { observer } from "mobx-react-lite";
import { axiosInstanceForGet } from "@/providers/data";
const MapDataContext = createContext<{ const MapDataContext = createContext<{
originalRouteData?: RouteData; originalRouteData?: RouteData;
originalStationData?: StationData[]; originalStationData?: StationData[];
originalSightData?: SightData[]; originalSightData?: SightData[];
routeData?: RouteData; routeData?: RouteData;
stationData?: StationDataWithLanguage; stationData?: StationData[];
sightData?: SightData[]; sightData?: SightData[];
isRouteLoading: boolean; isRouteLoading: boolean;
@ -60,247 +57,210 @@ const MapDataContext = createContext<{
saveChanges: () => {}, saveChanges: () => {},
}); });
type StationDataWithLanguage = { export function MapDataProvider({
[key: string]: StationData[]; children,
}; }: Readonly<{ children: ReactNode }>) {
export const MapDataProvider = observer( const { id: routeId } = useParams<{ id: string }>();
({ children }: Readonly<{ children: ReactNode }>) => { const apiUrl = useApiUrl();
const { id: routeId } = useParams<{ id: string }>();
const apiUrl = useApiUrl();
const [originalRouteData, setOriginalRouteData] = useState<RouteData>(); const [originalRouteData, setOriginalRouteData] = useState<RouteData>();
const [originalStationData, setOriginalStationData] = const [originalStationData, setOriginalStationData] =
useState<StationData[]>(); useState<StationData[]>();
const [originalSightData, setOriginalSightData] = useState<SightData[]>(); const [originalSightData, setOriginalSightData] = useState<SightData[]>();
const [routeData, setRouteData] = useState<RouteData>(); const [routeData, setRouteData] = useState<RouteData>();
const [stationData, setStationData] = useState<StationDataWithLanguage>({ const [stationData, setStationData] = useState<StationData[]>();
RU: [], const [sightData, setSightData] = useState<SightData[]>();
EN: [],
ZH: [], const [routeChanges, setRouteChanges] = useState<RouteData>({} as RouteData);
const [stationChanges, setStationChanges] = useState<StationPatchData[]>([]);
const [sightChanges, setSightChanges] = useState<SightPatchData[]>([]);
const { data: routeQuery, isLoading: isRouteLoading } = useCustom({
url: `${apiUrl}/route/${routeId}`,
method: "get",
});
const { data: stationQuery, isLoading: isStationLoading } = useCustom({
url: `${apiUrl}/route/${routeId}/station`,
method: "get",
});
const { data: sightQuery, isLoading: isSightLoading } = useCustom({
url: `${apiUrl}/route/${routeId}/sight`,
method: "get",
});
useEffect(() => {
// if not undefined, set original data
if (routeQuery?.data) setOriginalRouteData(routeQuery.data as RouteData);
if (stationQuery?.data)
setOriginalStationData(stationQuery.data as StationData[]);
if (sightQuery?.data) setOriginalSightData(sightQuery.data as SightData[]);
console.log("queries", routeQuery, stationQuery, sightQuery);
}, [routeQuery, stationQuery, sightQuery]);
useEffect(() => {
// combine changes with original data
if (originalRouteData)
setRouteData({ ...originalRouteData, ...routeChanges });
if (originalStationData) setStationData(originalStationData);
if (originalSightData) setSightData(originalSightData);
}, [
originalRouteData,
originalStationData,
originalSightData,
routeChanges,
stationChanges,
sightChanges,
]);
function setScaleRange(min: number, max: number) {
setRouteChanges((prev) => {
return { ...prev, scale_min: min, scale_max: max };
}); });
const [sightData, setSightData] = useState<SightData[]>();
const [routeChanges, setRouteChanges] = useState<Partial<RouteData>>({});
const [stationChanges, setStationChanges] = useState<StationPatchData[]>(
[]
);
const [sightChanges, setSightChanges] = useState<SightPatchData[]>([]);
const { language } = languageStore;
const [isRouteLoading, setIsRouteLoading] = useState(true);
const [isStationLoading, setIsStationLoading] = useState(true);
const [isSightLoading, setIsSightLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
setIsRouteLoading(true);
setIsStationLoading(true);
setIsSightLoading(true);
const [
routeResponse,
ruStationResponse,
enStationResponse,
zhStationResponse,
sightResponse,
] = await Promise.all([
axiosInstanceForGet(language).get(`/route/${routeId}`),
axiosInstanceForGet("ru").get(`/route/${routeId}/station`),
axiosInstanceForGet("en").get(`/route/${routeId}/station`),
axiosInstanceForGet("zh").get(`/route/${routeId}/station`),
axiosInstanceForGet(language).get(`/route/${routeId}/sight`),
]);
setOriginalRouteData(routeResponse.data as RouteData);
setOriginalStationData(ruStationResponse.data as StationData[]);
setStationData({
ru: ruStationResponse.data as StationData[],
en: enStationResponse.data as StationData[],
zh: zhStationResponse.data as StationData[],
});
setOriginalSightData(sightResponse.data as SightData[]);
setIsRouteLoading(false);
setIsStationLoading(false);
setIsSightLoading(false);
} catch (error) {
console.error("Error fetching data:", error);
setIsRouteLoading(false);
setIsStationLoading(false);
setIsSightLoading(false);
}
};
fetchData();
}, [routeId]);
useEffect(() => {
// combine changes with original data
if (originalRouteData)
setRouteData({ ...originalRouteData, ...routeChanges });
if (originalSightData) setSightData(originalSightData);
}, [
originalRouteData,
originalSightData,
routeChanges,
stationChanges,
sightChanges,
]);
function setScaleRange(min: number, max: number) {
setRouteChanges((prev) => {
return { ...prev, scale_min: min, scale_max: max };
});
}
function setMapRotation(rotation: number) {
setRouteChanges((prev) => {
return { ...prev, rotate: rotation };
});
}
function setMapCenter(x: number, y: number) {
setRouteChanges((prev) => {
return { ...prev, center_latitude: x, center_longitude: y };
});
}
async function saveChanges() {
await axiosInstance.patch(`/route/${routeId}`, routeData);
await saveStationChanges();
await saveSightChanges();
}
async function saveStationChanges() {
for (const station of stationChanges) {
const response = await axiosInstance.patch(
`/route/${routeId}/station`,
station
);
}
}
async function saveSightChanges() {
console.log("sightChanges", sightChanges);
for (const sight of sightChanges) {
const response = await axiosInstance.patch(
`/route/${routeId}/sight`,
sight
);
}
}
function setStationOffset(stationId: number, x: number, y: number) {
setStationChanges((prev) => {
let found = prev.find((station) => station.station_id === stationId);
if (found) {
found.offset_x = x;
found.offset_y = y;
return prev.map((station) => {
if (station.station_id === stationId) {
return found;
}
return station;
});
} else {
const foundStation = stationData.ru?.find(
(station) => station.id === stationId
);
if (foundStation) {
return [
...prev,
{
station_id: stationId,
offset_x: x,
offset_y: y,
transfers: foundStation.transfers,
},
];
}
return prev;
}
});
}
function setSightCoordinates(
sightId: number,
latitude: number,
longitude: number
) {
setSightChanges((prev) => {
let found = prev.find((sight) => sight.sight_id === sightId);
if (found) {
found.latitude = latitude;
found.longitude = longitude;
return prev.map((sight) => {
if (sight.sight_id === sightId) {
return found;
}
return sight;
});
} else {
const foundSight = sightData?.find((sight) => sight.id === sightId);
if (foundSight) {
return [
...prev,
{
sight_id: sightId,
latitude,
longitude,
},
];
}
return prev;
}
});
}
useEffect(() => {
console.log("sightChanges", sightChanges);
}, [sightChanges]);
const value = useMemo(
() => ({
originalRouteData,
originalStationData,
originalSightData,
routeData,
stationData,
sightData,
isRouteLoading,
isStationLoading,
isSightLoading,
setScaleRange,
setMapRotation,
setMapCenter,
saveChanges,
setStationOffset,
setSightCoordinates,
}),
[
originalRouteData,
originalStationData,
originalSightData,
routeData,
stationData,
sightData,
isRouteLoading,
isStationLoading,
isSightLoading,
]
);
return (
<MapDataContext.Provider value={value}>
{children}
</MapDataContext.Provider>
);
} }
);
function setMapRotation(rotation: number) {
setRouteChanges((prev) => {
return { ...prev, rotate: rotation };
});
}
function setMapCenter(x: number, y: number) {
setRouteChanges((prev) => {
return { ...prev, center_latitude: x, center_longitude: y };
});
}
async function saveChanges() {
await axiosInstance.patch(`/route/${routeId}`, routeData);
await saveStationChanges();
await saveSightChanges();
}
async function saveStationChanges() {
for (const station of stationChanges) {
const response = await axiosInstance.patch(
`/route/${routeId}/station`,
station
);
}
}
async function saveSightChanges() {
console.log("sightChanges", sightChanges);
for (const sight of sightChanges) {
const response = await axiosInstance.patch(
`/route/${routeId}/sight`,
sight
);
}
}
function setStationOffset(stationId: number, x: number, y: number) {
setStationChanges((prev) => {
let found = prev.find((station) => station.station_id === stationId);
if (found) {
found.offset_x = x;
found.offset_y = y;
return prev.map((station) => {
if (station.station_id === stationId) {
return found;
}
return station;
});
} else {
const foundStation = stationData?.find(
(station) => station.id === stationId
);
if (foundStation) {
return [
...prev,
{
station_id: stationId,
offset_x: x,
offset_y: y,
transfers: foundStation.transfers,
},
];
}
return prev;
}
});
}
function setSightCoordinates(
sightId: number,
latitude: number,
longitude: number
) {
setSightChanges((prev) => {
let found = prev.find((sight) => sight.sight_id === sightId);
if (found) {
found.latitude = latitude;
found.longitude = longitude;
return prev.map((sight) => {
if (sight.sight_id === sightId) {
return found;
}
return sight;
});
} else {
const foundSight = sightData?.find((sight) => sight.id === sightId);
if (foundSight) {
return [
...prev,
{
sight_id: sightId,
latitude,
longitude,
},
];
}
return prev;
}
});
}
useEffect(() => {
console.log("sightChanges", sightChanges);
}, [sightChanges]);
const value = useMemo(
() => ({
originalRouteData: originalRouteData,
originalStationData: originalStationData,
originalSightData: originalSightData,
routeData: routeData,
stationData: stationData,
sightData: sightData,
isRouteLoading,
isStationLoading,
isSightLoading,
setScaleRange,
setMapRotation,
setMapCenter,
saveChanges,
setStationOffset,
setSightCoordinates,
}),
[
originalRouteData,
originalStationData,
originalSightData,
routeData,
stationData,
sightData,
isRouteLoading,
isStationLoading,
isSightLoading,
]
);
return (
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider>
);
}
export const useMapData = () => { export const useMapData = () => {
const context = useContext(MapDataContext); const context = useContext(MapDataContext);

View File

@ -5,228 +5,187 @@ import { useTransform } from "./TransformContext";
import { coordinatesToLocal, localToCoordinates } from "./utils"; import { coordinatesToLocal, localToCoordinates } from "./utils";
export function RightSidebar() { export function RightSidebar() {
const { const { routeData, setScaleRange, saveChanges, originalRouteData, setMapRotation, setMapCenter } = useMapData();
routeData, const { rotation, position, screenToLocal, screenCenter, rotateToAngle, setTransform } = useTransform();
setScaleRange, const [minScale, setMinScale] = useState<number>(1);
saveChanges, const [maxScale, setMaxScale] = useState<number>(10);
originalRouteData, const [localCenter, setLocalCenter] = useState<{x: number, y: number}>({x: 0, y: 0});
setMapRotation, const [rotationDegrees, setRotationDegrees] = useState<number>(0);
setMapCenter,
} = useMapData();
const {
rotation,
position,
screenToLocal,
screenCenter,
rotateToAngle,
setTransform,
} = useTransform();
const [minScale, setMinScale] = useState<number>(1);
const [maxScale, setMaxScale] = useState<number>(10);
const [localCenter, setLocalCenter] = useState<{ x: number; y: number }>({
x: 0,
y: 0,
});
const [rotationDegrees, setRotationDegrees] = useState<number>(0);
useEffect(() => { useEffect(() => {
if (originalRouteData) { if(originalRouteData) {
setMinScale(originalRouteData.scale_min ?? 1); setMinScale(originalRouteData.scale_min ?? 1);
setMaxScale(originalRouteData.scale_max ?? 10); setMaxScale(originalRouteData.scale_max ?? 10);
setRotationDegrees(originalRouteData.rotate ?? 0); setRotationDegrees(originalRouteData.rotate ?? 0);
setLocalCenter({ setLocalCenter({x: originalRouteData.center_latitude ?? 0, y: originalRouteData.center_longitude ?? 0});
x: originalRouteData.center_latitude ?? 0, }
y: originalRouteData.center_longitude ?? 0, }, [originalRouteData]);
});
}
}, [originalRouteData]);
useEffect(() => { useEffect(() => {
if (minScale && maxScale) { if(minScale && maxScale) {
setScaleRange(minScale, maxScale); setScaleRange(minScale, maxScale);
} }
}, [minScale, maxScale]); }, [minScale, maxScale]);
useEffect(() => {
setRotationDegrees(
((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360
);
}, [rotation]);
useEffect(() => {
setMapRotation(rotationDegrees);
}, [rotationDegrees]);
useEffect(() => { useEffect(() => {
const center = screenCenter ?? { x: 0, y: 0 }; setRotationDegrees((Math.round(rotation * 180 / Math.PI) % 360 + 360) % 360);
const localCenter = screenToLocal(center.x, center.y); }, [rotation]);
const coordinates = localToCoordinates(localCenter.x, localCenter.y); useEffect(() => {
setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude }); setMapRotation(rotationDegrees);
}, [position]); }, [rotationDegrees]);
useEffect(() => { useEffect(() => {
setMapCenter(localCenter.x, localCenter.y); const center = screenCenter ?? {x: 0, y: 0};
}, [localCenter]); const localCenter = screenToLocal(center.x, center.y);
const coordinates = localToCoordinates(localCenter.x, localCenter.y);
setLocalCenter({x: coordinates.latitude, y: coordinates.longitude});
}, [position]);
function setRotationFromDegrees(degrees: number) {
rotateToAngle((degrees * Math.PI) / 180);
}
function pan({ x, y }: { x: number; y: number }) { useEffect(() => {
const coordinates = coordinatesToLocal(x, y); setMapCenter(localCenter.x, localCenter.y);
setTransform(coordinates.x, coordinates.y); }, [localCenter]);
}
if (!routeData) { function setRotationFromDegrees(degrees: number) {
console.error("routeData is null"); rotateToAngle(degrees * Math.PI / 180);
return null; }
}
return ( function pan({x, y}: {x: number, y: number}) {
<Stack const coordinates = coordinatesToLocal(x,y);
position="absolute" setTransform(coordinates.x, coordinates.y);
right={8} }
top={8}
bottom={8}
p={2}
gap={1}
minWidth="400px"
bgcolor="primary.main"
border="1px solid #e0e0e0"
borderRadius={2}
>
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
Детали о достопримечательностях
</Typography>
<Stack spacing={2} direction="row" alignItems="center"> if(!routeData) {
<TextField console.error("routeData is null");
type="number" return null;
label="Минимальный масштаб" }
variant="filled"
value={minScale}
onChange={(e) => setMinScale(Number(e.target.value))}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
slotProps={{
input: {
min: 0.1,
},
}}
/>
<TextField
type="number"
label="Максимальный масштаб"
variant="filled"
value={maxScale}
onChange={(e) => setMaxScale(Number(e.target.value))}
style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
slotProps={{
input: {
min: 0.1,
},
}}
/>
</Stack>
<TextField return (
type="number" <Stack
label="Поворот (в градусах)" position="absolute" right={8} top={8} bottom={8} p={2}
variant="filled" gap={1}
value={rotationDegrees} minWidth="400px" bgcolor="primary.main"
onChange={(e) => { border="1px solid #e0e0e0" borderRadius={2}
const value = Number(e.target.value); >
if (!isNaN(value)) { <Typography variant="h6" sx={{ mb: 2 }} textAlign="center">
setRotationFromDegrees(value); Детали о достопримечательностях
} </Typography>
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
slotProps={{
input: {
min: 0,
max: 360,
},
}}
/>
<Stack direction="row" spacing={2}> <Stack spacing={2} direction="row" alignItems="center">
<TextField <TextField
type="number" type="number"
label="Центр карты, широта" label="Минимальный масштаб"
variant="filled" variant="filled"
value={Math.round(localCenter.x * 100000) / 100000} value={minScale}
onChange={(e) => { onChange={(e) => setMinScale(Number(e.target.value))}
setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) })); style={{backgroundColor: "#222", borderRadius: 4}}
pan({ x: Number(e.target.value), y: localCenter.y }); sx={{
}} '& .MuiInputLabel-root.Mui-focused': {
style={{ backgroundColor: "#222", borderRadius: 4 }} color: "#fff"
sx={{ }
"& .MuiInputLabel-root": { }}
color: "#fff", slotProps={{
}, input: {
"& .MuiInputBase-input": { min: 0.1
color: "#fff", }
}, }}
}} />
/> <TextField
<TextField type="number"
type="number" label="Максимальный масштаб"
label="Центр карты, высота" variant="filled"
variant="filled" value={maxScale}
value={Math.round(localCenter.y * 100000) / 100000} onChange={(e) => setMaxScale(Number(e.target.value))}
onChange={(e) => { style={{backgroundColor: "#222", borderRadius: 4}}
setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) })); sx={{
pan({ x: localCenter.x, y: Number(e.target.value) }); '& .MuiInputLabel-root.Mui-focused': {
}} color: "#fff"
style={{ backgroundColor: "#222", borderRadius: 4 }} }
sx={{ }}
"& .MuiInputLabel-root": { slotProps={{
color: "#fff", input: {
}, min: 0.1
"& .MuiInputBase-input": { }
color: "#fff", }}
}, />
}} </Stack>
/>
</Stack>
<Button <TextField
variant="contained" type="number"
color="secondary" label="Поворот (в градусах)"
sx={{ mt: 2 }} variant="filled"
onClick={() => { value={rotationDegrees}
saveChanges(); onChange={(e) => {
}} const value = Number(e.target.value);
> if (!isNaN(value)) {
Сохранить изменения setRotationFromDegrees(value);
</Button> }
</Stack> }}
); onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
}}
style={{backgroundColor: "#222", borderRadius: 4}}
sx={{
'& .MuiInputLabel-root.Mui-focused': {
color: "#fff"
}
}}
slotProps={{
input: {
min: 0,
max: 360
}
}}
/>
<Stack direction="row" spacing={2}>
<TextField
type="number"
label="Центр карты, широта"
variant="filled"
value={Math.round(localCenter.x*100000)/100000}
onChange={(e) => {
setLocalCenter(prev => ({...prev, x: Number(e.target.value)}))
pan({x: Number(e.target.value), y: localCenter.y});
}}
style={{backgroundColor: "#222", borderRadius: 4}}
sx={{
'& .MuiInputLabel-root.Mui-focused': {
color: "#fff"
}
}}
/>
<TextField
type="number"
label="Центр карты, высота"
variant="filled"
value={Math.round(localCenter.y*100000)/100000}
onChange={(e) => {
setLocalCenter(prev => ({...prev, y: Number(e.target.value)}))
pan({x: localCenter.x, y: Number(e.target.value)});
}}
style={{backgroundColor: "#222", borderRadius: 4}}
sx={{
'& .MuiInputLabel-root.Mui-focused': {
color: "#fff"
}
}}
/>
</Stack>
<Button
variant="contained"
color="secondary"
sx={{ mt: 2 }}
onClick={() => {
saveChanges();
}}
>
Сохранить изменения
</Button>
</Stack>
);
} }

View File

@ -1,148 +1,109 @@
import { FederatedMouseEvent, Graphics } from "pixi.js"; import { FederatedMouseEvent, Graphics } from "pixi.js";
import { import { BACKGROUND_COLOR, PATH_COLOR, STATION_RADIUS, STATION_OUTLINE_WIDTH, UP_SCALE } from "./Constants";
BACKGROUND_COLOR,
PATH_COLOR,
STATION_RADIUS,
STATION_OUTLINE_WIDTH,
UP_SCALE,
} from "./Constants";
import { useTransform } from "./TransformContext"; import { useTransform } from "./TransformContext";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { StationData } from "./types"; import { StationData } from "./types";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
import { coordinatesToLocal } from "./utils"; import { coordinatesToLocal } from "./utils";
import { observer } from "mobx-react-lite";
import { languageStore } from "@stores";
interface StationProps { interface StationProps {
station: StationData; station: StationData;
ruLabel: string | null;
} }
export const Station = observer( export function Station({
({ station, ruLabel }: Readonly<StationProps>) => { station
const draw = useCallback((g: Graphics) => { }: Readonly<StationProps>) {
g.clear(); const draw = useCallback((g: Graphics) => {
const coordinates = coordinatesToLocal( g.clear();
station.latitude, const coordinates = coordinatesToLocal(station.latitude, station.longitude);
station.longitude g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, STATION_RADIUS);
); g.fill({color: PATH_COLOR});
g.circle( g.stroke({color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH});
coordinates.x * UP_SCALE, }, []);
coordinates.y * UP_SCALE,
STATION_RADIUS
);
g.fill({ color: PATH_COLOR });
g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH });
}, []);
return ( return (
<pixiContainer> <pixiContainer>
<pixiGraphics draw={draw} /> <pixiGraphics draw={draw}/>
<StationLabel station={station} ruLabel={ruLabel} /> <StationLabel station={station}/>
</pixiContainer> </pixiContainer>
); );
} }
);
export const StationLabel = observer( export function StationLabel({
({ station, ruLabel }: Readonly<StationProps>) => { station
const { language } = languageStore; }: Readonly<StationProps>) {
const { rotation, scale } = useTransform(); const { rotation, scale } = useTransform();
const { setStationOffset } = useMapData(); const { setStationOffset } = useMapData();
const [position, setPosition] = useState({ const [position, setPosition] = useState({ x: station.offset_x, y: station.offset_y });
x: station.offset_x, const [isDragging, setIsDragging] = useState(false);
y: station.offset_y,
});
const [isDragging, setIsDragging] = useState(false);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [startMousePosition, setStartMousePosition] = useState({ const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
x: 0,
y: 0,
});
if (!station) { if(!station) {
console.error("station is null"); console.error("station is null");
return null; return null;
} }
const handlePointerDown = (e: FederatedMouseEvent) => { const handlePointerDown = (e: FederatedMouseEvent) => {
setIsDragging(true); setIsDragging(true);
setStartPosition({ setStartPosition({
x: position.x, x: position.x,
y: position.y, y: position.y
}); });
setStartMousePosition({ setStartMousePosition({
x: e.globalX, x: e.globalX,
y: e.globalY, y: e.globalY
}); });
e.stopPropagation(); e.stopPropagation();
}; };
const handlePointerMove = (e: FederatedMouseEvent) => { const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isDragging) return; if (!isDragging) return;
const dx = e.globalX - startMousePosition.x; const dx = (e.globalX - startMousePosition.x);
const dy = e.globalY - startMousePosition.y; const dy = (e.globalY - startMousePosition.y);
const newPosition = { const newPosition = {
x: startPosition.x + dx, x: startPosition.x + dx,
y: startPosition.y + dy, y: startPosition.y + dy
}; };
setPosition(newPosition); setPosition(newPosition);
setStationOffset(station.id, newPosition.x, newPosition.y); setStationOffset(station.id, newPosition.x, newPosition.y);
e.stopPropagation(); e.stopPropagation();
}; };
const handlePointerUp = (e: FederatedMouseEvent) => { const handlePointerUp = (e: FederatedMouseEvent) => {
setIsDragging(false); setIsDragging(false);
e.stopPropagation(); e.stopPropagation();
}; };
const coordinates = coordinatesToLocal(station.latitude, station.longitude); const coordinates = coordinatesToLocal(station.latitude, station.longitude);
return ( return (
<pixiContainer <pixiContainer
eventMode="static" eventMode='static'
interactive interactive
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove} onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp} onPointerUpOutside={handlePointerUp}
width={48} width={48}
height={48} height={48}
x={coordinates.x * UP_SCALE} x={coordinates.x * UP_SCALE}
y={coordinates.y * UP_SCALE} y={coordinates.y * UP_SCALE}
rotation={-rotation} rotation={-rotation}
> >
<pixiText <pixiText
anchor={{ x: 1, y: 0.5 }} anchor={{x: 0.5, y: 0.5}}
text={station.name} text={station.name}
position={{ position={{
x: position.x / scale + 24, x: position.x/scale,
y: position.y / scale, y: position.y/scale
}} }}
style={{ style={{
fontSize: 26, fontSize: 48,
fontWeight: "bold", fontWeight: 'bold',
fill: "#ffffff", fill: "#ffffff"
}} }}
/> />
</pixiContainer>
{ruLabel && ( );
<pixiText }
anchor={{ x: 1, y: -1 }}
text={ruLabel}
position={{
x: position.x / scale + 24,
y: position.y / scale,
}}
style={{
fontSize: 16,
fontWeight: "bold",
fill: "#CCCCCC",
}}
/>
)}
</pixiContainer>
);
}
);

View File

@ -1,204 +1,150 @@
import { import { createContext, ReactNode, useContext, useMemo, useState } from "react";
createContext,
ReactNode,
useContext,
useMemo,
useState,
useCallback,
} from "react";
import { SCALE_FACTOR, UP_SCALE } from "./Constants"; import { SCALE_FACTOR, UP_SCALE } from "./Constants";
const TransformContext = createContext<{
position: { x: number; y: number };
scale: number;
rotation: number;
screenCenter?: { x: number; y: number };
setPosition: React.Dispatch<React.SetStateAction<{ x: number; y: number }>>; const TransformContext = createContext<{
setScale: React.Dispatch<React.SetStateAction<number>>; position: { x: number, y: number },
setRotation: React.Dispatch<React.SetStateAction<number>>; scale: number,
screenToLocal: (x: number, y: number) => { x: number; y: number }; rotation: number,
localToScreen: (x: number, y: number) => { x: number; y: number }; screenCenter?: { x: number, y: number },
rotateToAngle: (to: number, fromPosition?: { x: number; y: number }) => void;
setTransform: ( setPosition: React.Dispatch<React.SetStateAction<{ x: number, y: number }>>,
latitude: number, setScale: React.Dispatch<React.SetStateAction<number>>,
longitude: number, setRotation: React.Dispatch<React.SetStateAction<number>>,
rotationDegrees?: number, screenToLocal: (x: number, y: number) => { x: number, y: number },
scale?: number localToScreen: (x: number, y: number) => { x: number, y: number },
) => void; rotateToAngle: (to: number, fromPosition?: {x: number, y: number}) => void,
setScreenCenter: React.Dispatch< setTransform: (latitude: number, longitude: number, rotationDegrees?: number, scale?: number) => void,
React.SetStateAction<{ x: number; y: number } | undefined> setScreenCenter: React.Dispatch<React.SetStateAction<{ x: number, y: number } | undefined>>
>;
}>({ }>({
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
scale: 1, scale: 1,
rotation: 0, rotation: 0,
screenCenter: undefined, screenCenter: undefined,
setPosition: () => {}, setPosition: () => {},
setScale: () => {}, setScale: () => {},
setRotation: () => {}, setRotation: () => {},
screenToLocal: () => ({ x: 0, y: 0 }), screenToLocal: () => ({ x: 0, y: 0 }),
localToScreen: () => ({ x: 0, y: 0 }), localToScreen: () => ({ x: 0, y: 0 }),
rotateToAngle: () => {}, rotateToAngle: () => {},
setTransform: () => {}, setTransform: () => {},
setScreenCenter: () => {}, setScreenCenter: () => {}
}); });
// Provider component // Provider component
export const TransformProvider = ({ children }: { children: ReactNode }) => { export const TransformProvider = ({ children }: { children: ReactNode }) => {
const [position, setPosition] = useState({ x: 0, y: 0 }); const [position, setPosition] = useState({ x: 0, y: 0 });
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
const [rotation, setRotation] = useState(0); const [rotation, setRotation] = useState(0);
const [screenCenter, setScreenCenter] = useState<{ x: number; y: number }>(); const [screenCenter, setScreenCenter] = useState<{x: number, y: number}>();
const screenToLocal = useCallback( function screenToLocal(screenX: number, screenY: number) {
(screenX: number, screenY: number) => { // Translate point relative to current pan position
// Translate point relative to current pan position const translatedX = (screenX - position.x) / scale;
const translatedX = (screenX - position.x) / scale; const translatedY = (screenY - position.y) / scale;
const translatedY = (screenY - position.y) / scale;
// Rotate point around center // Rotate point around center
const cosRotation = Math.cos(-rotation); // Negative rotation to reverse transform const cosRotation = Math.cos(-rotation); // Negative rotation to reverse transform
const sinRotation = Math.sin(-rotation); const sinRotation = Math.sin(-rotation);
const rotatedX = translatedX * cosRotation - translatedY * sinRotation; const rotatedX = translatedX * cosRotation - translatedY * sinRotation;
const rotatedY = translatedX * sinRotation + translatedY * cosRotation; const rotatedY = translatedX * sinRotation + translatedY * cosRotation;
return { return {
x: rotatedX / UP_SCALE, x: rotatedX / UP_SCALE,
y: rotatedY / UP_SCALE, y: rotatedY / UP_SCALE
}; };
}, }
[position.x, position.y, scale, rotation]
);
// Inverse of screenToLocal // Inverse of screenToLocal
const localToScreen = useCallback( function localToScreen(localX: number, localY: number) {
(localX: number, localY: number) => {
const upscaledX = localX * UP_SCALE;
const upscaledY = localY * UP_SCALE;
const cosRotation = Math.cos(rotation); const upscaledX = localX * UP_SCALE;
const sinRotation = Math.sin(rotation); const upscaledY = localY * UP_SCALE;
const rotatedX = upscaledX * cosRotation - upscaledY * sinRotation;
const rotatedY = upscaledX * sinRotation + upscaledY * cosRotation;
const translatedX = rotatedX * scale + position.x; const cosRotation = Math.cos(rotation);
const translatedY = rotatedY * scale + position.y; const sinRotation = Math.sin(rotation);
const rotatedX = upscaledX * cosRotation - upscaledY * sinRotation;
const rotatedY = upscaledX * sinRotation + upscaledY * cosRotation;
return { const translatedX = rotatedX*scale + position.x;
x: translatedX, const translatedY = rotatedY*scale + position.y;
y: translatedY,
};
},
[position.x, position.y, scale, rotation]
);
const rotateToAngle = useCallback( return {
(to: number, fromPosition?: { x: number; y: number }) => { x: translatedX,
const rotationDiff = to - rotation; y: translatedY
};
}
const center = screenCenter ?? { x: 0, y: 0 };
const cosDelta = Math.cos(rotationDiff);
const sinDelta = Math.sin(rotationDiff);
const currentFromPosition = fromPosition ?? position;
const newPosition = { function rotateToAngle(to: number, fromPosition?: {x: number, y: number}) {
x: setRotation(to);
center.x * (1 - cosDelta) + const rotationDiff = to - rotation;
currentFromPosition.x * cosDelta +
(center.y - currentFromPosition.y) * sinDelta,
y:
center.y * (1 - cosDelta) +
currentFromPosition.y * cosDelta +
(currentFromPosition.x - center.x) * sinDelta,
};
// Update both rotation and position in a single batch to avoid stale closure const center = screenCenter ?? {x: 0, y: 0};
setRotation(to); const cosDelta = Math.cos(rotationDiff);
setPosition(newPosition); const sinDelta = Math.sin(rotationDiff);
},
[rotation, position, screenCenter]
);
const setTransform = useCallback( fromPosition ??= position;
(
latitude: number,
longitude: number,
rotationDegrees?: number,
useScale?: number
) => {
const selectedRotation =
rotationDegrees !== undefined
? (rotationDegrees * Math.PI) / 180
: rotation;
const selectedScale =
useScale !== undefined ? useScale / SCALE_FACTOR : scale;
const center = screenCenter ?? { x: 0, y: 0 };
console.log("center", center.x, center.y); setPosition({
x: center.x * (1 - cosDelta) + fromPosition.x * cosDelta + (center.y - fromPosition.y) * sinDelta,
y: center.y * (1 - cosDelta) + fromPosition.y * cosDelta + (fromPosition.x - center.x) * sinDelta
});
}
const newPosition = { function setTransform(latitude: number, longitude: number, rotationDegrees?: number, useScale ?: number) {
x: -latitude * UP_SCALE * selectedScale, const selectedRotation = rotationDegrees ? (rotationDegrees * Math.PI / 180) : rotation;
y: -longitude * UP_SCALE * selectedScale, const selectedScale = useScale ? useScale/SCALE_FACTOR : scale;
}; const center = screenCenter ?? {x: 0, y: 0};
console.log("center", center.x, center.y);
const newPosition = {
x: -latitude * UP_SCALE * selectedScale,
y: -longitude * UP_SCALE * selectedScale
};
const cosRot = Math.cos(selectedRotation); const cos = Math.cos(selectedRotation);
const sinRot = Math.sin(selectedRotation); const sin = Math.sin(selectedRotation);
// Translate point relative to center, rotate, then translate back // Translate point relative to center, rotate, then translate back
const dx = newPosition.x; const dx = newPosition.x;
const dy = newPosition.y; const dy = newPosition.y;
newPosition.x = dx * cosRot - dy * sinRot + center.x; newPosition.x = (dx * cos - dy * sin) + center.x;
newPosition.y = dx * sinRot + dy * cosRot + center.y; newPosition.y = (dx * sin + dy * cos) + center.y;
// Batch state updates to avoid intermediate renders
setPosition(newPosition);
setRotation(selectedRotation);
setScale(selectedScale);
},
[rotation, scale, screenCenter]
);
const value = useMemo( setPosition(newPosition);
() => ({ setRotation(selectedRotation);
position, setScale(selectedScale);
scale, }
rotation,
screenCenter,
setPosition,
setScale,
setRotation,
rotateToAngle,
screenToLocal,
localToScreen,
setTransform,
setScreenCenter,
}),
[
position,
scale,
rotation,
screenCenter,
rotateToAngle,
screenToLocal,
localToScreen,
setTransform,
]
);
return ( const value = useMemo(() => ({
<TransformContext.Provider value={value}> position,
{children} scale,
</TransformContext.Provider> rotation,
); screenCenter,
setPosition,
setScale,
setRotation,
rotateToAngle,
screenToLocal,
localToScreen,
setTransform,
setScreenCenter
}), [position, scale, rotation, screenCenter]);
return (
<TransformContext.Provider value={value}>
{children}
</TransformContext.Provider>
);
}; };
// Custom hook for easy access to transform values // Custom hook for easy access to transform values
export const useTransform = () => { export const useTransform = () => {
const context = useContext(TransformContext); const context = useContext(TransformContext);
if (!context) { if (!context) {
throw new Error("useTransform must be used within a TransformProvider"); throw new Error('useTransform must be used within a TransformProvider');
} }
return context; return context;
}; };

View File

@ -3,32 +3,37 @@ import { useCallback } from "react";
import { PATH_COLOR, PATH_WIDTH } from "./Constants"; import { PATH_COLOR, PATH_WIDTH } from "./Constants";
import { coordinatesToLocal } from "./utils"; import { coordinatesToLocal } from "./utils";
interface TravelPathProps { interface TravelPathProps {
points: { x: number; y: number }[]; points: {x: number, y: number}[];
} }
export function TravelPath({ points }: Readonly<TravelPathProps>) { export function TravelPath({
const draw = useCallback( points
(g: Graphics) => { }: Readonly<TravelPathProps>) {
g.clear();
const coordStart = coordinatesToLocal(points[0].x, points[0].y);
g.moveTo(coordStart.x, coordStart.y);
for (let i = 1; i <= points.length - 1; i++) {
const coordinates = coordinatesToLocal(points[i].x, points[i].y);
g.lineTo(coordinates.x, coordinates.y);
}
g.stroke({
color: PATH_COLOR,
width: PATH_WIDTH,
});
},
[points]
);
if (points.length === 0) { const draw = useCallback((g: Graphics) => {
console.error("points is empty"); g.clear();
return null; const coordStart = coordinatesToLocal(points[0].x, points[0].y);
} g.moveTo(coordStart.x, coordStart.y);
for (let i = 1; i < points.length - 1; i++) {
const coordinates = coordinatesToLocal(points[i].x, points[i].y);
g.lineTo(coordinates.x, coordinates.y);
}
g.stroke({
color: PATH_COLOR,
width: PATH_WIDTH
});
}, [points]);
return <pixiGraphics draw={draw} />; if(points.length === 0) {
console.error("points is empty");
return null;
}
return (
<pixiGraphics
draw={draw}
/>
);
} }

View File

@ -1,43 +1,31 @@
import { Stack, Typography } from "@mui/material"; import { Stack, Typography } from "@mui/material";
export function Widgets() { export function Widgets() {
return ( return (
<Stack <Stack
direction="column" direction="column" spacing={2}
spacing={2} position="absolute"
position="absolute" top={32} left={32}
top={32} sx={{ pointerEvents: 'none' }}
left={32} >
sx={{ pointerEvents: "none" }} <Stack bgcolor="primary.main"
> width={361} height={96}
<Stack p={2} m={2}
bgcolor="primary.main" borderRadius={2}
width={361} alignItems="center"
height={96} justifyContent="center"
p={2} >
m={2} <Typography variant="h6">Станция</Typography>
borderRadius={2} </Stack>
alignItems="center" <Stack bgcolor="primary.main"
justifyContent="center" width={223} height={262}
> p={2} m={2}
<Typography variant="h6" sx={{ color: "#fff" }}> borderRadius={2}
Станция alignItems="center"
</Typography> justifyContent="center"
</Stack> >
<Stack <Typography variant="h6">Погода</Typography>
bgcolor="primary.main" </Stack>
width={223} </Stack>
height={262} )
p={2}
m={2}
borderRadius={2}
alignItems="center"
justifyContent="center"
>
<Typography variant="h6" sx={{ color: "#fff" }}>
Погода
</Typography>
</Stack>
</Stack>
);
} }

View File

@ -1,14 +1,18 @@
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { Application, ApplicationRef, extend } from "@pixi/react";
import { import {
Container, Application,
Graphics, ApplicationRef,
Sprite, extend
Texture, } from '@pixi/react';
TilingSprite, import {
Text, Container,
} from "pixi.js"; Graphics,
Sprite,
Texture,
TilingSprite,
Text
} from 'pixi.js';
import { Stack } from "@mui/material"; import { Stack } from "@mui/material";
import { MapDataProvider, useMapData } from "./MapDataContext"; import { MapDataProvider, useMapData } from "./MapDataContext";
import { TransformProvider, useTransform } from "./TransformContext"; import { TransformProvider, useTransform } from "./TransformContext";
@ -21,156 +25,128 @@ import { LeftSidebar } from "./LeftSidebar";
import { RightSidebar } from "./RightSidebar"; import { RightSidebar } from "./RightSidebar";
import { Widgets } from "./Widgets"; import { Widgets } from "./Widgets";
import { coordinatesToLocal } from "./utils"; import { coordinatesToLocal } from "./utils";
import { LanguageSwitch } from "@/components/LanguageSwitch";
import { languageStore } from "@stores";
import { observer } from "mobx-react-lite";
extend({ extend({
Container, Container,
Graphics, Graphics,
Sprite, Sprite,
Texture, Texture,
TilingSprite, TilingSprite,
Text, Text
}); });
export const RoutePreview = () => { export const RoutePreview = () => {
return ( return (
<MapDataProvider> <MapDataProvider>
<TransformProvider> <TransformProvider>
<Stack direction="row" height="100vh" width="100vw" overflow="hidden"> <Stack direction="row" height="100vh" width="100vw" overflow="hidden">
<div <LeftSidebar />
style={{ <Stack direction="row" flex={1} position="relative" height="100%">
position: "absolute", <Widgets />
top: 0, <RouteMap />
left: "50%", <RightSidebar />
transform: "translateX(-50%)", </Stack>
zIndex: 1000,
}} </Stack>
> </TransformProvider>
<LanguageSwitch /> </MapDataProvider>
</div> );
<LeftSidebar />
<Stack direction="row" flex={1} position="relative" height="100%">
<Widgets />
<RouteMap />
<RightSidebar />
</Stack>
</Stack>
</TransformProvider>
</MapDataProvider>
);
}; };
export const RouteMap = observer(() => {
const { language } = languageStore;
const { setPosition, screenToLocal, setTransform, screenCenter } =
useTransform();
const { routeData, stationData, sightData, originalRouteData } = useMapData();
console.log(stationData);
const [points, setPoints] = useState<{ x: number; y: number }[]>([]);
const [isSetup, setIsSetup] = useState(false);
const parentRef = useRef<HTMLDivElement>(null); export function RouteMap() {
const { setPosition, screenToLocal, setTransform, screenCenter } = useTransform();
const {
routeData, stationData, sightData, originalRouteData
} = useMapData();
const [points, setPoints] = useState<{x: number, y: number}[]>([]);
const [isSetup, setIsSetup] = useState(false);
useEffect(() => { const parentRef = useRef<HTMLDivElement>(null);
if (originalRouteData) {
const path = originalRouteData?.path;
const points =
path?.map(([x, y]: [number, number]) => ({
x: x * UP_SCALE,
y: y * UP_SCALE,
})) ?? [];
setPoints(points);
}
}, [originalRouteData]);
useEffect(() => { useEffect(() => {
if (isSetup || !screenCenter) { if (originalRouteData) {
return; const path = originalRouteData?.path;
} const points = path?.map(([x, y]: [number, number]) => ({x: x * UP_SCALE, y: y * UP_SCALE})) ?? [];
setPoints(points);
}
}, [originalRouteData]);
if ( useEffect(() => {
originalRouteData?.center_latitude === if(isSetup || !screenCenter) {
originalRouteData?.center_longitude && return;
originalRouteData?.center_latitude === 0 }
) {
if (points.length > 0) {
let boundingBox = {
from: { x: Infinity, y: Infinity },
to: { x: -Infinity, y: -Infinity },
};
for (const point of points) {
boundingBox.from.x = Math.min(boundingBox.from.x, point.x);
boundingBox.from.y = Math.min(boundingBox.from.y, point.y);
boundingBox.to.x = Math.max(boundingBox.to.x, point.x);
boundingBox.to.y = Math.max(boundingBox.to.y, point.y);
}
const newCenter = {
x: -(boundingBox.from.x + boundingBox.to.x) / 2,
y: -(boundingBox.from.y + boundingBox.to.y) / 2,
};
setPosition(newCenter);
setIsSetup(true);
}
} else if (
originalRouteData?.center_latitude &&
originalRouteData?.center_longitude
) {
const coordinates = coordinatesToLocal(
originalRouteData?.center_latitude,
originalRouteData?.center_longitude
);
setTransform( if (
coordinates.x, originalRouteData?.center_latitude === originalRouteData?.center_longitude &&
coordinates.y, originalRouteData?.center_latitude === 0
originalRouteData?.rotate, ) {
originalRouteData?.scale_min if (points.length > 0) {
); let boundingBox = {
setIsSetup(true); from: {x: Infinity, y: Infinity},
} to: {x: -Infinity, y: -Infinity}
}, [ };
points, for (const point of points) {
originalRouteData?.center_latitude, boundingBox.from.x = Math.min(boundingBox.from.x, point.x);
originalRouteData?.center_longitude, boundingBox.from.y = Math.min(boundingBox.from.y, point.y);
originalRouteData?.rotate, boundingBox.to.x = Math.max(boundingBox.to.x, point.x);
isSetup, boundingBox.to.y = Math.max(boundingBox.to.y, point.y);
screenCenter, }
]); const newCenter = {
x: -(boundingBox.from.x + boundingBox.to.x) / 2,
y: -(boundingBox.from.y + boundingBox.to.y) / 2
};
setPosition(newCenter);
setIsSetup(true);
}
} else if (
originalRouteData?.center_latitude &&
originalRouteData?.center_longitude
) {
const coordinates = coordinatesToLocal(originalRouteData?.center_latitude, originalRouteData?.center_longitude);
if (!routeData || !stationData || !sightData) { setTransform(
console.error("routeData, stationData or sightData is null"); coordinates.x,
return <div>Loading...</div>; coordinates.y,
} originalRouteData?.rotate,
originalRouteData?.scale_min
);
setIsSetup(true);
}
}, [points, originalRouteData?.center_latitude, originalRouteData?.center_longitude, originalRouteData?.rotate, isSetup, screenCenter]);
return (
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
<Application resizeTo={parentRef} background="#fff">
<InfiniteCanvas>
<TravelPath points={points} />
{stationData[language].map((obj, index) => (
<Station
station={obj}
key={obj.id}
ruLabel={
language === "ru"
? stationData.en[index].name
: stationData.ru[index].name
}
/>
))}
<pixiGraphics if (!routeData || !stationData || !sightData) {
draw={(g) => { console.error("routeData, stationData or sightData is null");
g.clear(); return <div>Loading...</div>;
const localCenter = screenToLocal(0, 0); }
g.circle(localCenter.x, localCenter.y, 10);
g.fill("#fff");
}} return (
/> <div style={{width: "100%", height:"100%"}} ref={parentRef}>
</InfiniteCanvas> <Application
</Application> resizeTo={parentRef}
</div> background="#fff"
); >
}); <InfiniteCanvas>
<TravelPath points={points}/>
{stationData?.map((obj) => (
<Station station={obj} key={obj.id}/>
))}
{sightData?.map((obj, index) => (
<Sight sight={obj} id={index} key={obj.id}/>
))}
<pixiGraphics
draw={(g) => {
g.clear();
const localCenter = screenToLocal(0,0);
g.circle(localCenter.x, localCenter.y, 10);
g.fill("#fff");
}}
/>
</InfiniteCanvas>
</Application>
</div>
)
}

View File

@ -280,19 +280,13 @@ export const RouteCreate = () => {
<TextField <TextField
{...register("scale_min", { {...register("scale_min", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => { setValueAs: (value) => Number(value),
if (Number(value) < 0) {
return 0;
}
return Number(value);
},
})} })}
error={!!(errors as any)?.scale_min} error={!!(errors as any)?.scale_min}
helperText={(errors as any)?.scale_min?.message} helperText={(errors as any)?.scale_min?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} slotProps={{ inputLabel: { shrink: true } }}
inputProps={{ min: 0 }}
type="number" type="number"
label={"Масштаб (мин)"} label={"Масштаб (мин)"}
name="scale_min" name="scale_min"
@ -301,19 +295,13 @@ export const RouteCreate = () => {
<TextField <TextField
{...register("scale_max", { {...register("scale_max", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => { setValueAs: (value) => Number(value),
if (Number(value) < 0) {
return 0;
}
return Number(value);
},
})} })}
error={!!(errors as any)?.scale_max} error={!!(errors as any)?.scale_max}
helperText={(errors as any)?.scale_max?.message} helperText={(errors as any)?.scale_max?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} slotProps={{ inputLabel: { shrink: true } }}
inputProps={{ min: 0 }}
type="number" type="number"
label={"Масштаб (макс)"} label={"Масштаб (макс)"}
name="scale_max" name="scale_max"

View File

@ -9,7 +9,7 @@ import {
} from "@mui/material"; } from "@mui/material";
import { Edit, useAutocomplete } from "@refinedev/mui"; import { Edit, useAutocomplete } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form"; import { useForm } from "@refinedev/react-hook-form";
import { Controller, useWatch } from "react-hook-form"; import { Controller } from "react-hook-form";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { LinkedItems } from "../../components/LinkedItems"; import { LinkedItems } from "../../components/LinkedItems";
import { import {
@ -76,11 +76,6 @@ export const RouteEdit = observer(() => {
...META_LANGUAGE(language), ...META_LANGUAGE(language),
}); });
const carrierId = useWatch({ control, name: "carrier_id" });
const cityId = carrierAutocompleteProps.options.find(
(option) => option.id === carrierId
)?.city_id;
const { autocompleteProps: governorAppealAutocompleteProps } = const { autocompleteProps: governorAppealAutocompleteProps } =
useAutocomplete({ useAutocomplete({
resource: "article", resource: "article",
@ -317,12 +312,7 @@ export const RouteEdit = observer(() => {
<TextField <TextField
{...register("scale_min", { {...register("scale_min", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => { setValueAs: (value) => Number(value),
if (Number(value) < 0) {
return 0;
}
return Number(value);
},
})} })}
error={!!(errors as any)?.scale_min} error={!!(errors as any)?.scale_min}
helperText={(errors as any)?.scale_min?.message} helperText={(errors as any)?.scale_min?.message}
@ -337,12 +327,7 @@ export const RouteEdit = observer(() => {
<TextField <TextField
{...register("scale_max", { {...register("scale_max", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => { setValueAs: (value) => Number(value),
if (Number(value) < 0) {
return 0;
}
return Number(value);
},
})} })}
error={!!(errors as any)?.scale_max} error={!!(errors as any)?.scale_max}
helperText={(errors as any)?.scale_max?.message} helperText={(errors as any)?.scale_max?.message}
@ -408,19 +393,18 @@ export const RouteEdit = observer(() => {
parentResource="route" parentResource="route"
childResource="station" childResource="station"
fields={stationFields} fields={stationFields}
title="остановки" title="станции"
dragAllowed={true} dragAllowed={true}
cityId={cityId}
/> />
{/* <LinkedItems<VehicleItem> <LinkedItems<VehicleItem>
type="edit" type="edit"
parentId={routeId} parentId={routeId}
parentResource="route" parentResource="route"
childResource="vehicle" childResource="vehicle"
fields={vehicleFields} fields={vehicleFields}
title="транспортные средства" title="транспортные средства"
/> */} />
</> </>
)} )}

View File

@ -80,17 +80,17 @@ export const RouteShow = observer(() => {
parentResource="route" parentResource="route"
childResource="station" childResource="station"
fields={stationFields} fields={stationFields}
title="остановки" title="станции"
/> />
{/* <LinkedItems<VehicleItem> <LinkedItems<VehicleItem>
type="show" type="show"
parentId={record.id} parentId={record.id}
parentResource="route" parentResource="route"
childResource="vehicle" childResource="vehicle"
fields={vehicleFields} fields={vehicleFields}
title="транспортные средства" title="транспортные средства"
/> */} />
<LinkedItems<SightItem> <LinkedItems<SightItem>
type="show" type="show"
@ -103,7 +103,8 @@ export const RouteShow = observer(() => {
</> </>
)} )}
<Box sx={{ display: "flex", justifyContent: "flex-start" }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"

View File

@ -22,7 +22,6 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
export const SightCreate = observer(() => { export const SightCreate = observer(() => {
const { language, setLanguageAction } = languageStore; const { language, setLanguageAction } = languageStore;
const [coordinates, setCoordinates] = useState("");
const [sightData, setSightData] = useState({ const [sightData, setSightData] = useState({
name: EVERY_LANGUAGE(""), name: EVERY_LANGUAGE(""),
address: EVERY_LANGUAGE(""), address: EVERY_LANGUAGE(""),
@ -80,7 +79,10 @@ export const SightCreate = observer(() => {
}); });
const [namePreview, setNamePreview] = useState(""); const [namePreview, setNamePreview] = useState("");
const [coordinatesPreview, setCoordinatesPreview] = useState(""); const [coordinatesPreview, setCoordinatesPreview] = useState({
latitude: "",
longitude: "",
});
const [creatingArticleHeading, setCreatingArticleHeading] = const [creatingArticleHeading, setCreatingArticleHeading] =
useState<string>(""); useState<string>("");
@ -100,19 +102,13 @@ export const SightCreate = observer(() => {
const [previewArticlePreview, setPreviewArticlePreview] = useState(""); const [previewArticlePreview, setPreviewArticlePreview] = useState("");
const handleCoordinatesChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleCoordinatesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCoordinates(e.target.value); const [lat, lon] = e.target.value.split(",").map((s) => s.trim());
if (e.target.value) { setCoordinatesPreview({
const [lat, lon] = e.target.value.split(" ").map((s) => s.trim()); latitude: lat,
setCoordinatesPreview( longitude: lon,
`${lat ? Number(lat) : 0}, ${lon ? Number(lon) : 0}` });
); setValue("latitude", lat);
setValue("latitude", lat ? Number(lat) : 0); setValue("longitude", lon);
setValue("longitude", lon ? Number(lon) : 0);
} else {
setCoordinatesPreview("");
setValue("latitude", "");
setValue("longitude", "");
}
}; };
// Автокомплиты // Автокомплиты
@ -174,6 +170,13 @@ export const SightCreate = observer(() => {
setNamePreview(nameContent ?? ""); setNamePreview(nameContent ?? "");
}, [nameContent]); }, [nameContent]);
useEffect(() => {
setCoordinatesPreview({
latitude: latitudeContent || "",
longitude: longitudeContent || "",
});
}, [latitudeContent, longitudeContent]);
useEffect(() => { useEffect(() => {
const selectedCity = cityAutocompleteProps.options.find( const selectedCity = cityAutocompleteProps.options.find(
(option) => option.id === cityContent (option) => option.id === cityContent
@ -273,7 +276,7 @@ export const SightCreate = observer(() => {
/> />
<TextField <TextField
value={coordinates} value={`${coordinatesPreview.latitude}, ${coordinatesPreview.longitude}`}
onChange={handleCoordinatesChange} onChange={handleCoordinatesChange}
error={!!(errors as any)?.latitude} error={!!(errors as any)?.latitude}
helperText={(errors as any)?.latitude?.message} helperText={(errors as any)?.latitude?.message}
@ -283,11 +286,10 @@ export const SightCreate = observer(() => {
type="text" type="text"
label={"Координаты *"} label={"Координаты *"}
/> />
<input <input
type="hidden" type="hidden"
{...register("longitude", { {...register("longitude", {
value: coordinates.split(" ")[1], value: coordinatesPreview.longitude,
required: "Это поле является обязательным", required: "Это поле является обязательным",
valueAsNumber: true, valueAsNumber: true,
})} })}
@ -295,7 +297,7 @@ export const SightCreate = observer(() => {
<input <input
type="hidden" type="hidden"
{...register("latitude", { {...register("latitude", {
value: coordinates.split(" ")[0], value: coordinatesPreview.latitude,
required: "Это поле является обязательным", required: "Это поле является обязательным",
valueAsNumber: true, valueAsNumber: true,
})} })}
@ -431,7 +433,7 @@ export const SightCreate = observer(() => {
renderInput={(params) => ( renderInput={(params) => (
<TextField <TextField
{...params} {...params}
label="Выберите водяной знак (Левый верх)" label="Выберите водный знак (Левый верх)"
margin="normal" margin="normal"
variant="outlined" variant="outlined"
error={!!errors.watermark_lu} error={!!errors.watermark_lu}
@ -473,7 +475,7 @@ export const SightCreate = observer(() => {
renderInput={(params) => ( renderInput={(params) => (
<TextField <TextField
{...params} {...params}
label="Выберите водяной знак (Правый верх)" label="Выберите водный знак (Правый верх)"
margin="normal" margin="normal"
variant="outlined" variant="outlined"
error={!!errors.watermark_rd} error={!!errors.watermark_rd}
@ -499,13 +501,13 @@ export const SightCreate = observer(() => {
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id ?? ""); field.onChange(value?.id ?? "");
}} }}
getOptionLabel={(item) => (item ? item.service_name : "")} getOptionLabel={(item) => (item ? item.heading : "")}
isOptionEqualToValue={(option, value) => isOptionEqualToValue={(option, value) =>
option.id === value?.id option.id === value?.id
} }
filterOptions={(options, { inputValue }) => filterOptions={(options, { inputValue }) =>
options.filter((option) => options.filter((option) =>
option.service_name option.heading
.toLowerCase() .toLowerCase()
.includes(inputValue.toLowerCase()) .includes(inputValue.toLowerCase())
) )
@ -666,22 +668,21 @@ export const SightCreate = observer(() => {
</Typography> </Typography>
{/* Координаты */} {/* Координаты */}
{coordinatesPreview && ( <Typography variant="body1" sx={{ mb: 2 }}>
<Typography variant="body1" sx={{ mb: 2 }}> <Box component="span" sx={{ color: "text.secondary" }}>
<Box component="span" sx={{ color: "text.secondary" }}> Координаты:{" "}
Координаты:{" "} </Box>
</Box> <Box
<Box component="span"
component="span" sx={{
sx={{ color: (theme) =>
color: (theme) => theme.palette.mode === "dark" ? "grey.300" : "grey.800",
theme.palette.mode === "dark" ? "grey.300" : "grey.800", }}
}} >
> {`${coordinatesPreview.latitude}, ${coordinatesPreview.longitude}`}
{coordinatesPreview} </Box>
</Box> </Typography>
</Typography>
)}
{/* Обложка */} {/* Обложка */}
{thumbnailPreview && ( {thumbnailPreview && (
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>

View File

@ -70,7 +70,6 @@ export const SightEdit = observer(() => {
name: EVERY_LANGUAGE(""), name: EVERY_LANGUAGE(""),
address: EVERY_LANGUAGE(""), address: EVERY_LANGUAGE(""),
}); });
const [coordinates, setCoordinates] = useState("");
const { const {
saveButtonProps, saveButtonProps,
@ -164,13 +163,37 @@ export const SightEdit = observer(() => {
setValue("address", sightData.address[language]); setValue("address", sightData.address[language]);
}, [language, sightData, setValue]); }, [language, sightData, setValue]);
useEffect(() => {
const latitude = getValues("latitude");
const longitude = getValues("longitude");
if (latitude && longitude) {
setCoordinatesPreview({
latitude: latitude,
longitude: longitude,
});
}
}, [getValues]);
const handleCoordinatesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const [lat, lon] = e.target.value.split(",").map((s) => s.trim());
setCoordinatesPreview({
latitude: lat,
longitude: lon,
});
setValue("latitude", lat);
setValue("longitude", lon);
};
// Состояния для предпросмотра // Состояния для предпросмотра
const [creatingArticleHeading, setCreatingArticleHeading] = const [creatingArticleHeading, setCreatingArticleHeading] =
useState<string>(""); useState<string>("");
const [creatingArticleBody, setCreatingArticleBody] = useState<string>(""); const [creatingArticleBody, setCreatingArticleBody] = useState<string>("");
const [coordinatesPreview, setCoordinatesPreview] = useState(""); const [coordinatesPreview, setCoordinatesPreview] = useState({
latitude: "",
longitude: "",
});
const [selectedArticleIndex, setSelectedArticleIndex] = useState(-1); const [selectedArticleIndex, setSelectedArticleIndex] = useState(-1);
const [previewMediaFile, setPreviewMediaFile] = useState<MediaData>(); const [previewMediaFile, setPreviewMediaFile] = useState<MediaData>();
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null); const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
@ -220,25 +243,12 @@ export const SightEdit = observer(() => {
const watermarkRDContent = watch("watermark_rd"); const watermarkRDContent = watch("watermark_rd");
useEffect(() => { useEffect(() => {
if (latitudeContent && longitudeContent) { setCoordinatesPreview({
setCoordinates(`${latitudeContent} ${longitudeContent}`); latitude: latitudeContent ?? "",
} longitude: longitudeContent ?? "",
});
}, [latitudeContent, longitudeContent]); }, [latitudeContent, longitudeContent]);
const handleCoordinatesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCoordinates(e.target.value);
if (e.target.value) {
const [lat, lon] = e.target.value.split(" ").map((s) => s.trim());
setCoordinatesPreview(`${lat ?? "0"}, ${lon ?? "0"}`);
setValue("latitude", lat ?? "");
setValue("longitude", lon ?? "");
} else {
setCoordinatesPreview("");
setValue("latitude", "");
setValue("longitude", "");
}
};
useEffect(() => { useEffect(() => {
if (linkedArticles[selectedArticleIndex]?.id) { if (linkedArticles[selectedArticleIndex]?.id) {
getMedia(linkedArticles[selectedArticleIndex].id).then((media) => { getMedia(linkedArticles[selectedArticleIndex].id).then((media) => {
@ -495,6 +505,7 @@ export const SightEdit = observer(() => {
<input <input
type="hidden" type="hidden"
{...register("longitude", { {...register("longitude", {
value: coordinatesPreview.longitude,
required: "Это поле является обязательным", required: "Это поле является обязательным",
valueAsNumber: true, valueAsNumber: true,
})} })}
@ -502,6 +513,7 @@ export const SightEdit = observer(() => {
<input <input
type="hidden" type="hidden"
{...register("latitude", { {...register("latitude", {
value: coordinatesPreview.latitude,
required: "Это поле является обязательным", required: "Это поле является обязательным",
valueAsNumber: true, valueAsNumber: true,
})} })}
@ -594,7 +606,7 @@ export const SightEdit = observer(() => {
renderInput={(params) => ( renderInput={(params) => (
<TextField <TextField
{...params} {...params}
label="Выберите водяной знак (Левый верх)" label="Выберите водный знак (Левый верх)"
margin="normal" margin="normal"
variant="outlined" variant="outlined"
error={!!errors.arms} error={!!errors.arms}
@ -638,7 +650,7 @@ export const SightEdit = observer(() => {
renderInput={(params) => ( renderInput={(params) => (
<TextField <TextField
{...params} {...params}
label="Выберите водяной знак (Правый вверх)" label="Выберите водный знак (Правый вверх)"
margin="normal" margin="normal"
variant="outlined" variant="outlined"
error={!!errors.arms} error={!!errors.arms}
@ -668,14 +680,14 @@ export const SightEdit = observer(() => {
setLeftArticleData(undefined); setLeftArticleData(undefined);
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.service_name : ""; return item ? item.heading : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id; return option.id === value?.id;
}} }}
filterOptions={(options, { inputValue }) => { filterOptions={(options, { inputValue }) => {
return options.filter((option) => return options.filter((option) =>
option.service_name option.heading
.toLowerCase() .toLowerCase()
.includes(inputValue.toLowerCase()) .includes(inputValue.toLowerCase())
); );
@ -782,7 +794,6 @@ export const SightEdit = observer(() => {
color: (theme) => color: (theme) =>
theme.palette.mode === "dark" ? "grey.300" : "grey.800", theme.palette.mode === "dark" ? "grey.300" : "grey.800",
mb: 3, mb: 3,
mt: 3,
}} }}
> >
{name} {name}
@ -979,14 +990,14 @@ export const SightEdit = observer(() => {
setLeftArticleData(undefined); setLeftArticleData(undefined);
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.service_name : ""; return item ? item.heading : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id; return option.id === value?.id;
}} }}
filterOptions={(options, { inputValue }) => { filterOptions={(options, { inputValue }) => {
return options.filter((option) => return options.filter((option) =>
option.service_name option.heading
.toLowerCase() .toLowerCase()
.includes(inputValue.toLowerCase()) .includes(inputValue.toLowerCase())
); );
@ -1468,7 +1479,7 @@ export const SightEdit = observer(() => {
renderInput={(params) => ( renderInput={(params) => (
<TextField <TextField
{...params} {...params}
label="Выберите водяной знак (Левый верх)" label="Выберите водный знак (Левый верх)"
margin="normal" margin="normal"
variant="outlined" variant="outlined"
error={!!errors.arms} error={!!errors.arms}
@ -1512,7 +1523,7 @@ export const SightEdit = observer(() => {
renderInput={(params) => ( renderInput={(params) => (
<TextField <TextField
{...params} {...params}
label="Выберите водяной знак (Правый вверх)" label="Выберите водный знак (Правый вверх)"
margin="normal" margin="normal"
variant="outlined" variant="outlined"
error={!!errors.arms} error={!!errors.arms}
@ -1524,7 +1535,7 @@ export const SightEdit = observer(() => {
/> />
<TextField <TextField
value={coordinates} value={`${coordinatesPreview.latitude}, ${coordinatesPreview.longitude}`}
onChange={handleCoordinatesChange} onChange={handleCoordinatesChange}
error={!!(errors as any)?.latitude} error={!!(errors as any)?.latitude}
helperText={(errors as any)?.latitude?.message} helperText={(errors as any)?.latitude?.message}
@ -1676,7 +1687,7 @@ export const SightEdit = observer(() => {
: "grey.800", : "grey.800",
}} }}
> >
{coordinatesPreview} {`${coordinatesPreview.latitude}, ${coordinatesPreview.longitude}`}
</Box> </Box>
</Typography> </Typography>
</Box> </Box>

View File

@ -56,7 +56,6 @@ export const SightList = observer(() => {
display: "flex", display: "flex",
align: "left", align: "left",
headerAlign: "left", headerAlign: "left",
flex: 1,
}, },
{ {
field: "latitude", field: "latitude",

View File

@ -41,19 +41,40 @@ export const StationCreate = () => {
}, },
}); });
const [coordinates, setCoordinates] = useState(""); const [coordinatesPreview, setCoordinatesPreview] = useState({
latitude: "",
longitude: "",
});
const handleCoordinatesChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleCoordinatesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCoordinates(e.target.value); const [lat, lon] = e.target.value.split(",").map((s) => s.trim());
const [lat, lon] = e.target.value setCoordinatesPreview({
.replace(/,/g, "") // Remove all commas from the string latitude: lat,
.split(" ") longitude: lon,
.map((s) => s.trim()); });
console.log(lat, lon);
setValue("latitude", lat); setValue("latitude", lat);
setValue("longitude", lon); setValue("longitude", lon);
}; };
const latitudeContent = watch("latitude");
const longitudeContent = watch("longitude");
useEffect(() => {
setCoordinatesPreview({
latitude: latitudeContent || "",
longitude: longitudeContent || "",
});
}, [latitudeContent, longitudeContent]);
useEffect(() => {
const latitude = getValues("latitude");
const longitude = getValues("longitude");
if (latitude && longitude) {
setCoordinatesPreview({
latitude: latitude,
longitude: longitude,
});
}
}, [getValues]);
const directions = [ const directions = [
{ {
label: "Прямой", label: "Прямой",
@ -167,7 +188,7 @@ export const StationCreate = () => {
)} )}
/> />
<TextField <TextField
value={coordinates} value={`${coordinatesPreview.latitude}, ${coordinatesPreview.longitude}`}
onChange={handleCoordinatesChange} onChange={handleCoordinatesChange}
error={!!(errors as any)?.latitude} error={!!(errors as any)?.latitude}
helperText={(errors as any)?.latitude?.message} helperText={(errors as any)?.latitude?.message}
@ -177,11 +198,10 @@ export const StationCreate = () => {
type="text" type="text"
label={"Координаты *"} label={"Координаты *"}
/> />
<input <input
type="hidden" type="hidden"
{...register("latitude", { {...register("latitude", {
value: coordinates.split(",")[0], value: coordinatesPreview.latitude,
setValueAs: (value) => { setValueAs: (value) => {
if (value === "") { if (value === "") {
return 0; return 0;
@ -193,7 +213,7 @@ export const StationCreate = () => {
<input <input
type="hidden" type="hidden"
{...register("longitude", { {...register("longitude", {
value: coordinates.split(",")[1], value: coordinatesPreview.longitude,
setValueAs: (value) => { setValueAs: (value) => {
if (value === "") { if (value === "") {
return 0; return 0;

View File

@ -40,24 +40,24 @@ export const StationEdit = observer(() => {
system_name: "", system_name: "",
description: "", description: "",
address: "", address: "",
latitude: 0, latitude: "",
longitude: 0, longitude: "",
}, },
en: { en: {
name: "", name: "",
system_name: "", system_name: "",
description: "", description: "",
address: "", address: "",
latitude: 0, latitude: "",
longitude: 0, longitude: "",
}, },
zh: { zh: {
name: "", name: "",
system_name: "", system_name: "",
description: "", description: "",
address: "", address: "",
latitude: 0, latitude: "",
longitude: 0, longitude: "",
}, },
}); });
@ -69,8 +69,8 @@ export const StationEdit = observer(() => {
system_name: watch("system_name") ?? "", system_name: watch("system_name") ?? "",
description: watch("description") ?? "", description: watch("description") ?? "",
address: watch("address") ?? "", address: watch("address") ?? "",
latitude: Number(watch("latitude")) || 0, latitude: watch("latitude") ?? "",
longitude: Number(watch("longitude")) || 0, longitude: watch("longitude") ?? "",
}, },
})); }));
}; };
@ -120,7 +120,7 @@ export const StationEdit = observer(() => {
if (stationData[language as keyof typeof stationData]?.name) { if (stationData[language as keyof typeof stationData]?.name) {
setValue("name", stationData[language as keyof typeof stationData]?.name); setValue("name", stationData[language as keyof typeof stationData]?.name);
} }
if (stationData[language as keyof typeof stationData]?.system_name) { if (stationData[language as keyof typeof stationData]?.address) {
setValue( setValue(
"system_name", "system_name",
stationData[language as keyof typeof stationData]?.system_name || "" stationData[language as keyof typeof stationData]?.system_name || ""
@ -132,20 +132,16 @@ export const StationEdit = observer(() => {
stationData[language as keyof typeof stationData]?.description || "" stationData[language as keyof typeof stationData]?.description || ""
); );
} }
if ( if (stationData[language as keyof typeof stationData]?.latitude) {
stationData[language as keyof typeof stationData]?.latitude !== undefined
) {
setValue( setValue(
"latitude", "latitude",
stationData[language as keyof typeof stationData]?.latitude || 0 stationData[language as keyof typeof stationData]?.latitude || ""
); );
} }
if ( if (stationData[language as keyof typeof stationData]?.longitude) {
stationData[language as keyof typeof stationData]?.longitude !== undefined
) {
setValue( setValue(
"longitude", "longitude",
stationData[language as keyof typeof stationData]?.longitude || 0 stationData[language as keyof typeof stationData]?.longitude || ""
); );
} }
}, [language, stationData, setValue]); }, [language, stationData, setValue]);
@ -154,36 +150,28 @@ export const StationEdit = observer(() => {
setLanguageAction("ru"); setLanguageAction("ru");
}, []); }, []);
const [coordinates, setCoordinates] = useState("");
const { id: stationId } = useParams<{ id: string }>(); const { id: stationId } = useParams<{ id: string }>();
const handleCoordinatesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const [lat, lon] = e.target.value.split(",").map((s) => s.trim());
setCoordinatesPreview({
latitude: lat,
longitude: lon,
});
setValue("latitude", lat);
setValue("longitude", lon);
};
const latitudeContent = watch("latitude"); const latitudeContent = watch("latitude");
const longitudeContent = watch("longitude"); const longitudeContent = watch("longitude");
useEffect(() => { useEffect(() => {
if (latitudeContent && longitudeContent) { setCoordinatesPreview({
setCoordinates(`${latitudeContent} ${longitudeContent}`); latitude: latitudeContent || "",
} longitude: longitudeContent || "",
});
}, [latitudeContent, longitudeContent]); }, [latitudeContent, longitudeContent]);
const handleCoordinatesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCoordinates(e.target.value);
if (e.target.value) {
const [lat, lon] = e.target.value
.replace(/,/g, "") // Remove all commas from the string
.split(" ")
.map((s) => s.trim());
setCoordinates(`${lat ?? 0} ${lon ?? 0}`);
setValue("latitude", lat ?? 0);
setValue("longitude", lon ?? 0);
} else {
setCoordinates("");
setValue("latitude", "");
setValue("longitude", "");
}
};
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({ const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
resource: "city", resource: "city",
onSearch: (value) => [ onSearch: (value) => [
@ -204,8 +192,6 @@ export const StationEdit = observer(() => {
}, },
}); });
const cityId = watch("city_id");
return ( return (
<Edit saveButtonProps={saveButtonProps}> <Edit saveButtonProps={saveButtonProps}>
<Box <Box
@ -294,7 +280,7 @@ export const StationEdit = observer(() => {
/> />
<TextField <TextField
value={coordinates} value={`${coordinatesPreview.latitude}, ${coordinatesPreview.longitude}`}
onChange={handleCoordinatesChange} onChange={handleCoordinatesChange}
error={!!(errors as any)?.latitude} error={!!(errors as any)?.latitude}
helperText={(errors as any)?.latitude?.message} helperText={(errors as any)?.latitude?.message}
@ -307,28 +293,12 @@ export const StationEdit = observer(() => {
<input <input
type="hidden" type="hidden"
{...register("latitude", { {...register("latitude", {
valueAsNumber: true, value: coordinatesPreview.latitude,
value: Number(coordinates.split(" ")[0]),
setValueAs: (value) => {
if (value === "") {
return 0;
}
return Number(value);
},
})} })}
/> />
<input <input
type="hidden" type="hidden"
{...register("longitude", { {...register("longitude", { value: coordinatesPreview.longitude })}
valueAsNumber: true,
value: Number(coordinates.split(" ")[1]),
setValueAs: (value) => {
if (value === "") {
return 0;
}
return Number(value);
},
})}
/> />
<Controller <Controller
@ -383,7 +353,6 @@ export const StationEdit = observer(() => {
fields={sightFields} fields={sightFields}
title="достопримечательности" title="достопримечательности"
dragAllowed={false} dragAllowed={false}
cityId={cityId}
/> />
)} )}
</Edit> </Edit>

View File

@ -7,41 +7,14 @@ import {
ShowButton, ShowButton,
useDataGrid, useDataGrid,
} from "@refinedev/mui"; } from "@refinedev/mui";
import React, { useEffect, useState } from "react"; import React, { useEffect } from "react";
import { VEHICLE_TYPES } from "../../lib/constants"; import { VEHICLE_TYPES } from "../../lib/constants";
import { localeText } from "../../locales/ru/localeText"; import { localeText } from "../../locales/ru/localeText";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { languageStore } from "../../store/LanguageStore"; import { languageStore } from "../../store/LanguageStore";
import { axiosInstance } from "@providers";
export const VehicleList = observer(() => { export const VehicleList = observer(() => {
const [carriers, setCarriers] = useState<any[]>([]);
const [cities, setCities] = useState<any[]>([]);
useEffect(() => {
axiosInstance
.get("/carrier")
.then((res) => {
setCarriers(res.data);
})
.catch((err) => {
console.log(err);
});
}, []);
useEffect(() => {
axiosInstance
.get("/city")
.then((res) => {
setCities(res.data);
})
.catch((err) => {
console.log(err);
});
}, []);
const { language } = languageStore; const { language } = languageStore;
const { dataGridProps } = useDataGrid({ const { dataGridProps } = useDataGrid({
@ -98,38 +71,6 @@ export const VehicleList = observer(() => {
); );
}, },
}, },
{
field: "carrier-name",
headerName: "Перевозчик",
type: "string",
minWidth: 150,
display: "flex",
flex: 1,
align: "left",
headerAlign: "left",
renderCell: (params) => {
const value = params.row.carrier_id;
return carriers.find((carrier) => carrier.id === value)?.full_name;
},
},
{
field: "city-name",
headerName: "Город",
type: "string",
minWidth: 150,
flex: 1,
display: "flex",
align: "left",
headerAlign: "left",
renderCell: (params) => {
const value = params.row.carrier_id;
return cities.find(
(city) =>
city.id ===
carriers.find((carrier) => carrier.id === value)?.city_id
)?.name;
},
},
// { // {
// field: "city", // field: "city",
// headerName: "Город", // headerName: "Город",
@ -152,7 +93,7 @@ export const VehicleList = observer(() => {
return ( return (
<> <>
<EditButton hideText recordItemId={row.id} /> <EditButton hideText recordItemId={row.id} />
{/* <ShowButton hideText recordItemId={row.id} /> */} <ShowButton hideText recordItemId={row.id} />
<DeleteButton <DeleteButton
hideText hideText
confirmTitle="Вы уверены?" confirmTitle="Вы уверены?"
@ -163,7 +104,7 @@ export const VehicleList = observer(() => {
}, },
}, },
], ],
[carriers, cities] []
); );
return ( return (

View File

@ -3,7 +3,7 @@ import dataProvider from "@refinedev/simple-rest";
import { TOKEN_KEY } from "@providers"; import { TOKEN_KEY } from "@providers";
import axios from "axios"; import axios from "axios";
import { languageStore } from "@stores";
export const axiosInstance = axios.create({ export const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_KRBL_API, baseURL: import.meta.env.VITE_KRBL_API,
}); });
@ -22,22 +22,6 @@ axiosInstance.interceptors.request.use((config) => {
return config; return config;
}); });
export const axiosInstanceForGet = (language: string) => {
const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_KRBL_API,
});
axiosInstance.interceptors.request.use((config) => {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
config.headers["X-Language"] = language;
return config;
});
return axiosInstance;
};
const apiUrl = import.meta.env.VITE_KRBL_API; const apiUrl = import.meta.env.VITE_KRBL_API;
export const customDataProvider = dataProvider(apiUrl, axiosInstance); export const customDataProvider = dataProvider(apiUrl, axiosInstance);