Compare commits

..

4 Commits

Author SHA1 Message Date
5440126898 fix: Fix bug with route preview
All checks were successful
release-tag / release-image (push) Successful in 2m28s
2025-05-28 11:31:37 +03:00
a6a5288262 fix: Fix for correct usage 2025-05-28 11:08:28 +03:00
b837aae711 feat: Add language switcher for preview route 2025-05-27 20:57:14 +03:00
ede6681c28 feat: Update css file 2025-05-27 03:43:46 +03:00
25 changed files with 1396 additions and 970 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" href="/favicon.ico" /> <link rel="icon" type="image/png" href="/favicon-ship.png" />
<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,9 +19,7 @@
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-ship.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -70,6 +70,7 @@ 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) => {
@ -131,6 +132,7 @@ 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;
@ -216,7 +218,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);
@ -445,7 +447,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} options={availableItems.filter((item) => item.city_id == cityId)}
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 />
@ -456,6 +458,7 @@ 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,8 +143,17 @@ 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
variant="standard"
sx={{ width: "min-content", color: "white" }}
> >
<FormControl variant="standard" sx={{ width: "min-content" }}>
{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

@ -1,11 +1,16 @@
import {ProjectIcon} from './Icons' import { Ship } from "lucide-react";
import { ProjectIcon } from "./Icons";
export default function SidebarTitle({collapsed}: {collapsed: boolean}) { export default function SidebarTitle({ collapsed }: { collapsed: boolean }) {
return ( return (
<div style={{display: 'flex', alignItems: 'center', whiteSpace: 'nowrap'}}> <div
<ProjectIcon style={{color: '#7f6b58'}} /> style={{ display: "flex", alignItems: "center", whiteSpace: "nowrap" }}
>
<Ship size={40} style={{ color: "#7f6b58" }} />
{!collapsed && <span style={{marginLeft: 8, fontWeight: 'bold'}}>Белые ночи</span>} {!collapsed && (
<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: 35px; width: 32px;
height: 35px; height: 32px;
color: #544044; color: rgba(79, 138, 95, 1);
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(84, 64, 68, 0.5); background-color: rgba(79, 138, 95, 0.05);
} }

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { useCustom, useApiUrl } from "@refinedev/core"; import { useApiUrl } from "@refinedev/core";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { import {
createContext, createContext,
@ -16,13 +16,16 @@ 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?: StationData[]; stationData?: StationDataWithLanguage;
sightData?: SightData[]; sightData?: SightData[];
isRouteLoading: boolean; isRouteLoading: boolean;
@ -57,9 +60,11 @@ const MapDataContext = createContext<{
saveChanges: () => {}, saveChanges: () => {},
}); });
export function MapDataProvider({ type StationDataWithLanguage = {
children, [key: string]: StationData[];
}: Readonly<{ children: ReactNode }>) { };
export const MapDataProvider = observer(
({ children }: Readonly<{ children: ReactNode }>) => {
const { id: routeId } = useParams<{ id: string }>(); const { id: routeId } = useParams<{ id: string }>();
const apiUrl = useApiUrl(); const apiUrl = useApiUrl();
@ -69,43 +74,75 @@ export function MapDataProvider({
const [originalSightData, setOriginalSightData] = useState<SightData[]>(); const [originalSightData, setOriginalSightData] = useState<SightData[]>();
const [routeData, setRouteData] = useState<RouteData>(); const [routeData, setRouteData] = useState<RouteData>();
const [stationData, setStationData] = useState<StationData[]>(); const [stationData, setStationData] = useState<StationDataWithLanguage>({
RU: [],
EN: [],
ZH: [],
});
const [sightData, setSightData] = useState<SightData[]>(); const [sightData, setSightData] = useState<SightData[]>();
const [routeChanges, setRouteChanges] = useState<RouteData>({} as RouteData); const [routeChanges, setRouteChanges] = useState<Partial<RouteData>>({});
const [stationChanges, setStationChanges] = useState<StationPatchData[]>([]); const [stationChanges, setStationChanges] = useState<StationPatchData[]>(
[]
);
const [sightChanges, setSightChanges] = useState<SightPatchData[]>([]); const [sightChanges, setSightChanges] = useState<SightPatchData[]>([]);
const { language } = languageStore;
const [isRouteLoading, setIsRouteLoading] = useState(true);
const [isStationLoading, setIsStationLoading] = useState(true);
const [isSightLoading, setIsSightLoading] = useState(true);
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(() => { useEffect(() => {
// if not undefined, set original data const fetchData = async () => {
if (routeQuery?.data) setOriginalRouteData(routeQuery.data as RouteData); try {
if (stationQuery?.data) setIsRouteLoading(true);
setOriginalStationData(stationQuery.data as StationData[]); setIsStationLoading(true);
if (sightQuery?.data) setOriginalSightData(sightQuery.data as SightData[]); setIsSightLoading(true);
console.log("queries", routeQuery, stationQuery, sightQuery);
}, [routeQuery, stationQuery, sightQuery]); 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(() => { useEffect(() => {
// combine changes with original data // combine changes with original data
if (originalRouteData) if (originalRouteData)
setRouteData({ ...originalRouteData, ...routeChanges }); setRouteData({ ...originalRouteData, ...routeChanges });
if (originalStationData) setStationData(originalStationData);
if (originalSightData) setSightData(originalSightData); if (originalSightData) setSightData(originalSightData);
}, [ }, [
originalRouteData, originalRouteData,
originalStationData,
originalSightData, originalSightData,
routeChanges, routeChanges,
stationChanges, stationChanges,
@ -169,7 +206,7 @@ export function MapDataProvider({
return station; return station;
}); });
} else { } else {
const foundStation = stationData?.find( const foundStation = stationData.ru?.find(
(station) => station.id === stationId (station) => station.id === stationId
); );
if (foundStation) { if (foundStation) {
@ -228,12 +265,12 @@ export function MapDataProvider({
const value = useMemo( const value = useMemo(
() => ({ () => ({
originalRouteData: originalRouteData, originalRouteData,
originalStationData: originalStationData, originalStationData,
originalSightData: originalSightData, originalSightData,
routeData: routeData, routeData,
stationData: stationData, stationData,
sightData: sightData, sightData,
isRouteLoading, isRouteLoading,
isStationLoading, isStationLoading,
isSightLoading, isSightLoading,
@ -258,9 +295,12 @@ export function MapDataProvider({
); );
return ( return (
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider> <MapDataContext.Provider value={value}>
{children}
</MapDataContext.Provider>
); );
} }
);
export const useMapData = () => { export const useMapData = () => {
const context = useContext(MapDataContext); const context = useContext(MapDataContext);

View File

@ -5,70 +5,96 @@ import { useTransform } from "./TransformContext";
import { coordinatesToLocal, localToCoordinates } from "./utils"; import { coordinatesToLocal, localToCoordinates } from "./utils";
export function RightSidebar() { export function RightSidebar() {
const { routeData, setScaleRange, saveChanges, originalRouteData, setMapRotation, setMapCenter } = useMapData(); const {
const { rotation, position, screenToLocal, screenCenter, rotateToAngle, setTransform } = useTransform(); routeData,
setScaleRange,
saveChanges,
originalRouteData,
setMapRotation,
setMapCenter,
} = useMapData();
const {
rotation,
position,
screenToLocal,
screenCenter,
rotateToAngle,
setTransform,
} = useTransform();
const [minScale, setMinScale] = useState<number>(1); const [minScale, setMinScale] = useState<number>(1);
const [maxScale, setMaxScale] = useState<number>(10); const [maxScale, setMaxScale] = useState<number>(10);
const [localCenter, setLocalCenter] = useState<{x: number, y: number}>({x: 0, y: 0}); const [localCenter, setLocalCenter] = useState<{ x: number; y: number }>({
x: 0,
y: 0,
});
const [rotationDegrees, setRotationDegrees] = useState<number>(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({x: originalRouteData.center_latitude ?? 0, y: originalRouteData.center_longitude ?? 0}); setLocalCenter({
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(() => { useEffect(() => {
setRotationDegrees((Math.round(rotation * 180 / Math.PI) % 360 + 360) % 360); setRotationDegrees(
((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360
);
}, [rotation]); }, [rotation]);
useEffect(() => { useEffect(() => {
setMapRotation(rotationDegrees); setMapRotation(rotationDegrees);
}, [rotationDegrees]); }, [rotationDegrees]);
useEffect(() => { useEffect(() => {
const center = screenCenter ?? {x: 0, y: 0}; const center = screenCenter ?? { x: 0, y: 0 };
const localCenter = screenToLocal(center.x, center.y); const localCenter = screenToLocal(center.x, center.y);
const coordinates = localToCoordinates(localCenter.x, localCenter.y); const coordinates = localToCoordinates(localCenter.x, localCenter.y);
setLocalCenter({x: coordinates.latitude, y: coordinates.longitude}); setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
}, [position]); }, [position]);
useEffect(() => { useEffect(() => {
setMapCenter(localCenter.x, localCenter.y); setMapCenter(localCenter.x, localCenter.y);
}, [localCenter]); }, [localCenter]);
function setRotationFromDegrees(degrees: number) { function setRotationFromDegrees(degrees: number) {
rotateToAngle(degrees * Math.PI / 180); rotateToAngle((degrees * Math.PI) / 180);
} }
function pan({x, y}: {x: number, y: number}) { function pan({ x, y }: { x: number; y: number }) {
const coordinates = coordinatesToLocal(x,y); const coordinates = coordinatesToLocal(x, y);
setTransform(coordinates.x, coordinates.y); setTransform(coordinates.x, coordinates.y);
} }
if(!routeData) { if (!routeData) {
console.error("routeData is null"); console.error("routeData is null");
return null; return null;
} }
return ( return (
<Stack <Stack
position="absolute" right={8} top={8} bottom={8} p={2} position="absolute"
right={8}
top={8}
bottom={8}
p={2}
gap={1} gap={1}
minWidth="400px" bgcolor="primary.main" minWidth="400px"
border="1px solid #e0e0e0" borderRadius={2} bgcolor="primary.main"
border="1px solid #e0e0e0"
borderRadius={2}
> >
<Typography variant="h6" sx={{ mb: 2 }} textAlign="center"> <Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
Детали о достопримечательностях Детали о достопримечательностях
</Typography> </Typography>
@ -79,16 +105,19 @@ export function RightSidebar() {
variant="filled" variant="filled"
value={minScale} value={minScale}
onChange={(e) => setMinScale(Number(e.target.value))} onChange={(e) => setMinScale(Number(e.target.value))}
style={{backgroundColor: "#222", borderRadius: 4}} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
'& .MuiInputLabel-root.Mui-focused': { "& .MuiInputLabel-root": {
color: "#fff" color: "#fff",
} },
"& .MuiInputBase-input": {
color: "#fff",
},
}} }}
slotProps={{ slotProps={{
input: { input: {
min: 0.1 min: 0.1,
} },
}} }}
/> />
<TextField <TextField
@ -97,16 +126,19 @@ export function RightSidebar() {
variant="filled" variant="filled"
value={maxScale} value={maxScale}
onChange={(e) => setMaxScale(Number(e.target.value))} onChange={(e) => setMaxScale(Number(e.target.value))}
style={{backgroundColor: "#222", borderRadius: 4}} style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }}
sx={{ sx={{
'& .MuiInputLabel-root.Mui-focused': { "& .MuiInputLabel-root": {
color: "#fff" color: "#fff",
} },
"& .MuiInputBase-input": {
color: "#fff",
},
}} }}
slotProps={{ slotProps={{
input: { input: {
min: 0.1 min: 0.1,
} },
}} }}
/> />
</Stack> </Stack>
@ -123,21 +155,24 @@ export function RightSidebar() {
} }
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === "Enter") {
e.currentTarget.blur(); e.currentTarget.blur();
} }
}} }}
style={{backgroundColor: "#222", borderRadius: 4}} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
'& .MuiInputLabel-root.Mui-focused': { "& .MuiInputLabel-root": {
color: "#fff" color: "#fff",
} },
"& .MuiInputBase-input": {
color: "#fff",
},
}} }}
slotProps={{ slotProps={{
input: { input: {
min: 0, min: 0,
max: 360 max: 360,
} },
}} }}
/> />
@ -146,32 +181,38 @@ export function RightSidebar() {
type="number" type="number"
label="Центр карты, широта" label="Центр карты, широта"
variant="filled" variant="filled"
value={Math.round(localCenter.x*100000)/100000} value={Math.round(localCenter.x * 100000) / 100000}
onChange={(e) => { onChange={(e) => {
setLocalCenter(prev => ({...prev, x: Number(e.target.value)})) setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) }));
pan({x: Number(e.target.value), y: localCenter.y}); pan({ x: Number(e.target.value), y: localCenter.y });
}} }}
style={{backgroundColor: "#222", borderRadius: 4}} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
'& .MuiInputLabel-root.Mui-focused': { "& .MuiInputLabel-root": {
color: "#fff" color: "#fff",
} },
"& .MuiInputBase-input": {
color: "#fff",
},
}} }}
/> />
<TextField <TextField
type="number" type="number"
label="Центр карты, высота" label="Центр карты, высота"
variant="filled" variant="filled"
value={Math.round(localCenter.y*100000)/100000} value={Math.round(localCenter.y * 100000) / 100000}
onChange={(e) => { onChange={(e) => {
setLocalCenter(prev => ({...prev, y: Number(e.target.value)})) setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) }));
pan({x: localCenter.x, y: Number(e.target.value)}); pan({ x: localCenter.x, y: Number(e.target.value) });
}} }}
style={{backgroundColor: "#222", borderRadius: 4}} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
'& .MuiInputLabel-root.Mui-focused': { "& .MuiInputLabel-root": {
color: "#fff" color: "#fff",
} },
"& .MuiInputBase-input": {
color: "#fff",
},
}} }}
/> />
</Stack> </Stack>

View File

@ -1,46 +1,68 @@
import { FederatedMouseEvent, Graphics } from "pixi.js"; import { FederatedMouseEvent, Graphics } from "pixi.js";
import { BACKGROUND_COLOR, PATH_COLOR, STATION_RADIUS, STATION_OUTLINE_WIDTH, UP_SCALE } from "./Constants"; import {
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 function Station({ export const Station = observer(
station ({ station, ruLabel }: Readonly<StationProps>) => {
}: Readonly<StationProps>) {
const draw = useCallback((g: Graphics) => { const draw = useCallback((g: Graphics) => {
g.clear(); g.clear();
const coordinates = coordinatesToLocal(station.latitude, station.longitude); const coordinates = coordinatesToLocal(
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, STATION_RADIUS); station.latitude,
g.fill({color: PATH_COLOR}); station.longitude
g.stroke({color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH}); );
g.circle(
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}/> <StationLabel station={station} ruLabel={ruLabel} />
</pixiContainer> </pixiContainer>
); );
} }
);
export function StationLabel({ export const StationLabel = observer(
station ({ station, ruLabel }: Readonly<StationProps>) => {
}: Readonly<StationProps>) { const { language } = languageStore;
const { rotation, scale } = useTransform(); const { rotation, scale } = useTransform();
const { setStationOffset } = useMapData(); const { setStationOffset } = useMapData();
const [position, setPosition] = useState({ x: station.offset_x, y: station.offset_y }); const [position, setPosition] = useState({
x: station.offset_x,
y: station.offset_y,
});
const [isDragging, setIsDragging] = useState(false); 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({ x: 0, y: 0 }); const [startMousePosition, setStartMousePosition] = useState({
x: 0,
y: 0,
});
if(!station) { if (!station) {
console.error("station is null"); console.error("station is null");
return null; return null;
} }
@ -49,22 +71,22 @@ export function StationLabel({
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);
@ -79,7 +101,7 @@ export function StationLabel({
return ( return (
<pixiContainer <pixiContainer
eventMode='static' eventMode="static"
interactive interactive
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove} onGlobalPointerMove={handlePointerMove}
@ -92,18 +114,35 @@ export function StationLabel({
rotation={-rotation} rotation={-rotation}
> >
<pixiText <pixiText
anchor={{x: 0.5, y: 0.5}} anchor={{ x: 0.5, y: 0.5 }}
text={station.name} text={station.name}
position={{ position={{
x: position.x/scale, x: position.x / scale,
y: position.y/scale y: position.y / scale,
}} }}
style={{ style={{
fontSize: 48, fontSize: 26,
fontWeight: 'bold', fontWeight: "bold",
fill: "#ffffff" fill: "#ffffff",
}} }}
/> />
{ruLabel && (
<pixiText
anchor={{ x: 0.5, y: -1 }}
text={ruLabel}
position={{
x: position.x / scale,
y: position.y / scale,
}}
style={{
fontSize: 16,
fontWeight: "bold",
fill: "#CCCCCC",
}}
/>
)}
</pixiContainer> </pixiContainer>
); );
} }
);

View File

@ -1,21 +1,34 @@
import { createContext, ReactNode, useContext, useMemo, useState } from "react"; import {
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<{ const TransformContext = createContext<{
position: { x: number, y: number }, position: { x: number; y: number };
scale: number, scale: number;
rotation: number, rotation: number;
screenCenter?: { x: number, y: number }, screenCenter?: { x: number; y: number };
setPosition: React.Dispatch<React.SetStateAction<{ x: number, y: number }>>, setPosition: React.Dispatch<React.SetStateAction<{ x: number; y: number }>>;
setScale: React.Dispatch<React.SetStateAction<number>>, setScale: React.Dispatch<React.SetStateAction<number>>;
setRotation: React.Dispatch<React.SetStateAction<number>>, setRotation: React.Dispatch<React.SetStateAction<number>>;
screenToLocal: (x: number, y: number) => { x: number, y: number }, screenToLocal: (x: number, y: number) => { x: number; y: number };
localToScreen: (x: number, y: number) => { x: number, y: number }, localToScreen: (x: number, y: number) => { x: number; y: number };
rotateToAngle: (to: number, fromPosition?: {x: number, y: number}) => void, rotateToAngle: (to: number, fromPosition?: { x: number; y: number }) => void;
setTransform: (latitude: number, longitude: number, rotationDegrees?: number, scale?: number) => void, setTransform: (
setScreenCenter: React.Dispatch<React.SetStateAction<{ x: number, y: number } | undefined>> latitude: number,
longitude: number,
rotationDegrees?: number,
scale?: number
) => void;
setScreenCenter: React.Dispatch<
React.SetStateAction<{ x: number; y: number } | undefined>
>;
}>({ }>({
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
scale: 1, scale: 1,
@ -28,7 +41,7 @@ const TransformContext = createContext<{
localToScreen: () => ({ x: 0, y: 0 }), localToScreen: () => ({ x: 0, y: 0 }),
rotateToAngle: () => {}, rotateToAngle: () => {},
setTransform: () => {}, setTransform: () => {},
setScreenCenter: () => {} setScreenCenter: () => {},
}); });
// Provider component // Provider component
@ -36,9 +49,10 @@ 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 }>();
function screenToLocal(screenX: number, screenY: number) { const screenToLocal = useCallback(
(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;
@ -51,13 +65,15 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
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
function localToScreen(localX: number, localY: number) { const localToScreen = useCallback(
(localX: number, localY: number) => {
const upscaledX = localX * UP_SCALE; const upscaledX = localX * UP_SCALE;
const upscaledY = localY * UP_SCALE; const upscaledY = localY * UP_SCALE;
@ -66,59 +82,86 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
const rotatedX = upscaledX * cosRotation - upscaledY * sinRotation; const rotatedX = upscaledX * cosRotation - upscaledY * sinRotation;
const rotatedY = upscaledX * sinRotation + upscaledY * cosRotation; const rotatedY = upscaledX * sinRotation + upscaledY * cosRotation;
const translatedX = rotatedX*scale + position.x; const translatedX = rotatedX * scale + position.x;
const translatedY = rotatedY*scale + position.y; const translatedY = rotatedY * scale + position.y;
return { return {
x: translatedX, x: translatedX,
y: translatedY y: translatedY,
}; };
} },
[position.x, position.y, scale, rotation]
);
const rotateToAngle = useCallback(
(to: number, fromPosition?: { x: number; y: number }) => {
function rotateToAngle(to: number, fromPosition?: {x: number, y: number}) {
setRotation(to);
const rotationDiff = to - rotation; const rotationDiff = to - rotation;
const center = screenCenter ?? {x: 0, y: 0}; const center = screenCenter ?? { x: 0, y: 0 };
const cosDelta = Math.cos(rotationDiff); const cosDelta = Math.cos(rotationDiff);
const sinDelta = Math.sin(rotationDiff); const sinDelta = Math.sin(rotationDiff);
fromPosition ??= position; const currentFromPosition = fromPosition ?? position;
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
});
}
function setTransform(latitude: number, longitude: number, rotationDegrees?: number, useScale ?: number) {
const selectedRotation = rotationDegrees ? (rotationDegrees * Math.PI / 180) : rotation;
const selectedScale = useScale ? useScale/SCALE_FACTOR : scale;
const center = screenCenter ?? {x: 0, y: 0};
console.log("center", center.x, center.y);
const newPosition = { const newPosition = {
x: -latitude * UP_SCALE * selectedScale, x:
y: -longitude * UP_SCALE * selectedScale center.x * (1 - cosDelta) +
currentFromPosition.x * cosDelta +
(center.y - currentFromPosition.y) * sinDelta,
y:
center.y * (1 - cosDelta) +
currentFromPosition.y * cosDelta +
(currentFromPosition.x - center.x) * sinDelta,
}; };
const cos = Math.cos(selectedRotation); // Update both rotation and position in a single batch to avoid stale closure
const sin = Math.sin(selectedRotation); setRotation(to);
setPosition(newPosition);
},
[rotation, position, screenCenter]
);
const setTransform = useCallback(
(
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);
const newPosition = {
x: -latitude * UP_SCALE * selectedScale,
y: -longitude * UP_SCALE * selectedScale,
};
const cosRot = Math.cos(selectedRotation);
const sinRot = 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 * cos - dy * sin) + center.x; newPosition.x = dx * cosRot - dy * sinRot + center.x;
newPosition.y = (dx * sin + dy * cos) + center.y; newPosition.y = dx * sinRot + dy * cosRot + center.y;
// Batch state updates to avoid intermediate renders
setPosition(newPosition); setPosition(newPosition);
setRotation(selectedRotation); setRotation(selectedRotation);
setScale(selectedScale); setScale(selectedScale);
} },
[rotation, scale, screenCenter]
);
const value = useMemo(() => ({ const value = useMemo(
() => ({
position, position,
scale, scale,
rotation, rotation,
@ -130,8 +173,19 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
screenToLocal, screenToLocal,
localToScreen, localToScreen,
setTransform, setTransform,
setScreenCenter setScreenCenter,
}), [position, scale, rotation, screenCenter]); }),
[
position,
scale,
rotation,
screenCenter,
rotateToAngle,
screenToLocal,
localToScreen,
setTransform,
]
);
return ( return (
<TransformContext.Provider value={value}> <TransformContext.Provider value={value}>
@ -144,7 +198,7 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
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,37 +3,32 @@ 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({ export function TravelPath({ points }: Readonly<TravelPathProps>) {
points const draw = useCallback(
}: Readonly<TravelPathProps>) { (g: Graphics) => {
const draw = useCallback((g: Graphics) => {
g.clear(); g.clear();
const coordStart = coordinatesToLocal(points[0].x, points[0].y); const coordStart = coordinatesToLocal(points[0].x, points[0].y);
g.moveTo(coordStart.x, coordStart.y); g.moveTo(coordStart.x, coordStart.y);
for (let i = 1; i < points.length - 1; i++) { for (let i = 1; i <= points.length - 1; i++) {
const coordinates = coordinatesToLocal(points[i].x, points[i].y); const coordinates = coordinatesToLocal(points[i].x, points[i].y);
g.lineTo(coordinates.x, coordinates.y); g.lineTo(coordinates.x, coordinates.y);
} }
g.stroke({ g.stroke({
color: PATH_COLOR, color: PATH_COLOR,
width: PATH_WIDTH width: PATH_WIDTH,
}); });
}, [points]); },
[points]
);
if(points.length === 0) { if (points.length === 0) {
console.error("points is empty"); console.error("points is empty");
return null; return null;
} }
return ( return <pixiGraphics draw={draw} />;
<pixiGraphics
draw={draw}
/>
);
} }

View File

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

View File

@ -1,18 +1,14 @@
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { import { Application, ApplicationRef, extend } from "@pixi/react";
Application,
ApplicationRef,
extend
} from '@pixi/react';
import { import {
Container, Container,
Graphics, Graphics,
Sprite, Sprite,
Texture, Texture,
TilingSprite, TilingSprite,
Text Text,
} from 'pixi.js'; } 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";
@ -25,6 +21,9 @@ 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,
@ -32,7 +31,7 @@ extend({
Sprite, Sprite,
Texture, Texture,
TilingSprite, TilingSprite,
Text Text,
}); });
export const RoutePreview = () => { export const RoutePreview = () => {
@ -40,26 +39,36 @@ export const RoutePreview = () => {
<MapDataProvider> <MapDataProvider>
<TransformProvider> <TransformProvider>
<Stack direction="row" height="100vh" width="100vw" overflow="hidden"> <Stack direction="row" height="100vh" width="100vw" overflow="hidden">
<div
style={{
position: "absolute",
top: 0,
left: "50%",
transform: "translateX(-50%)",
zIndex: 1000,
}}
>
<LanguageSwitch />
</div>
<LeftSidebar /> <LeftSidebar />
<Stack direction="row" flex={1} position="relative" height="100%"> <Stack direction="row" flex={1} position="relative" height="100%">
<Widgets /> <Widgets />
<RouteMap /> <RouteMap />
<RightSidebar /> <RightSidebar />
</Stack> </Stack>
</Stack> </Stack>
</TransformProvider> </TransformProvider>
</MapDataProvider> </MapDataProvider>
); );
}; };
export const RouteMap = observer(() => {
export function RouteMap() { const { language } = languageStore;
const { setPosition, screenToLocal, setTransform, screenCenter } = useTransform(); const { setPosition, screenToLocal, setTransform, screenCenter } =
const { useTransform();
routeData, stationData, sightData, originalRouteData const { routeData, stationData, sightData, originalRouteData } = useMapData();
} = useMapData(); console.log(stationData);
const [points, setPoints] = useState<{x: number, y: number}[]>([]); const [points, setPoints] = useState<{ x: number; y: number }[]>([]);
const [isSetup, setIsSetup] = useState(false); const [isSetup, setIsSetup] = useState(false);
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
@ -67,24 +76,29 @@ export function RouteMap() {
useEffect(() => { useEffect(() => {
if (originalRouteData) { if (originalRouteData) {
const path = originalRouteData?.path; const path = originalRouteData?.path;
const points = path?.map(([x, y]: [number, number]) => ({x: x * UP_SCALE, y: y * UP_SCALE})) ?? []; const points =
path?.map(([x, y]: [number, number]) => ({
x: x * UP_SCALE,
y: y * UP_SCALE,
})) ?? [];
setPoints(points); setPoints(points);
} }
}, [originalRouteData]); }, [originalRouteData]);
useEffect(() => { useEffect(() => {
if(isSetup || !screenCenter) { if (isSetup || !screenCenter) {
return; return;
} }
if ( if (
originalRouteData?.center_latitude === originalRouteData?.center_longitude && originalRouteData?.center_latitude ===
originalRouteData?.center_longitude &&
originalRouteData?.center_latitude === 0 originalRouteData?.center_latitude === 0
) { ) {
if (points.length > 0) { if (points.length > 0) {
let boundingBox = { let boundingBox = {
from: {x: Infinity, y: Infinity}, from: { x: Infinity, y: Infinity },
to: {x: -Infinity, y: -Infinity} to: { x: -Infinity, y: -Infinity },
}; };
for (const point of points) { for (const point of points) {
boundingBox.from.x = Math.min(boundingBox.from.x, point.x); boundingBox.from.x = Math.min(boundingBox.from.x, point.x);
@ -94,7 +108,7 @@ export function RouteMap() {
} }
const newCenter = { const newCenter = {
x: -(boundingBox.from.x + boundingBox.to.x) / 2, x: -(boundingBox.from.x + boundingBox.to.x) / 2,
y: -(boundingBox.from.y + boundingBox.to.y) / 2 y: -(boundingBox.from.y + boundingBox.to.y) / 2,
}; };
setPosition(newCenter); setPosition(newCenter);
setIsSetup(true); setIsSetup(true);
@ -103,7 +117,10 @@ export function RouteMap() {
originalRouteData?.center_latitude && originalRouteData?.center_latitude &&
originalRouteData?.center_longitude originalRouteData?.center_longitude
) { ) {
const coordinates = coordinatesToLocal(originalRouteData?.center_latitude, originalRouteData?.center_longitude); const coordinates = coordinatesToLocal(
originalRouteData?.center_latitude,
originalRouteData?.center_longitude
);
setTransform( setTransform(
coordinates.x, coordinates.x,
@ -113,34 +130,37 @@ export function RouteMap() {
); );
setIsSetup(true); setIsSetup(true);
} }
}, [points, originalRouteData?.center_latitude, originalRouteData?.center_longitude, originalRouteData?.rotate, isSetup, screenCenter]); }, [
points,
originalRouteData?.center_latitude,
originalRouteData?.center_longitude,
originalRouteData?.rotate,
isSetup,
screenCenter,
]);
if (!routeData || !stationData || !sightData) { if (!routeData || !stationData || !sightData) {
console.error("routeData, stationData or sightData is null"); console.error("routeData, stationData or sightData is null");
return <div>Loading...</div>; return <div>Loading...</div>;
} }
return ( return (
<div style={{width: "100%", height:"100%"}} ref={parentRef}> <div style={{ width: "100%", height: "100%" }} ref={parentRef}>
<Application <Application resizeTo={parentRef} background="#fff">
resizeTo={parentRef}
background="#fff"
>
<InfiniteCanvas> <InfiniteCanvas>
<TravelPath points={points}/> <TravelPath points={points} />
{stationData?.map((obj) => ( {stationData[language].map((obj, index) => (
<Station station={obj} key={obj.id}/> <Station
))} station={obj}
{sightData?.map((obj, index) => ( key={obj.id}
<Sight sight={obj} id={index} key={obj.id}/> ruLabel={language === "ru" ? null : stationData.ru[index].name}
/>
))} ))}
<pixiGraphics <pixiGraphics
draw={(g) => { draw={(g) => {
g.clear(); g.clear();
const localCenter = screenToLocal(0,0); const localCenter = screenToLocal(0, 0);
g.circle(localCenter.x, localCenter.y, 10); g.circle(localCenter.x, localCenter.y, 10);
g.fill("#fff"); g.fill("#fff");
}} }}
@ -148,5 +168,5 @@ export function RouteMap() {
</InfiniteCanvas> </InfiniteCanvas>
</Application> </Application>
</div> </div>
) );
} });

View File

@ -280,13 +280,19 @@ export const RouteCreate = () => {
<TextField <TextField
{...register("scale_min", { {...register("scale_min", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value), setValueAs: (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"
@ -295,13 +301,19 @@ export const RouteCreate = () => {
<TextField <TextField
{...register("scale_max", { {...register("scale_max", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value), setValueAs: (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 } from "react-hook-form"; import { Controller, useWatch } 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,6 +76,11 @@ 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",
@ -312,7 +317,12 @@ export const RouteEdit = observer(() => {
<TextField <TextField
{...register("scale_min", { {...register("scale_min", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value), setValueAs: (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}
@ -327,7 +337,12 @@ export const RouteEdit = observer(() => {
<TextField <TextField
{...register("scale_max", { {...register("scale_max", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value), setValueAs: (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}
@ -393,18 +408,19 @@ 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,8 +103,7 @@ 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

@ -433,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}
@ -475,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}

View File

@ -606,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}
@ -650,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}
@ -1479,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}
@ -1523,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}

View File

@ -192,6 +192,8 @@ export const StationEdit = observer(() => {
}, },
}); });
const cityId = watch("city_id");
return ( return (
<Edit saveButtonProps={saveButtonProps}> <Edit saveButtonProps={saveButtonProps}>
<Box <Box
@ -353,6 +355,7 @@ export const StationEdit = observer(() => {
fields={sightFields} fields={sightFields}
title="достопримечательности" title="достопримечательности"
dragAllowed={false} dragAllowed={false}
cityId={cityId}
/> />
)} )}
</Edit> </Edit>

View File

@ -7,14 +7,41 @@ import {
ShowButton, ShowButton,
useDataGrid, useDataGrid,
} from "@refinedev/mui"; } from "@refinedev/mui";
import React, { useEffect } from "react"; import React, { useEffect, useState } 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({
@ -71,6 +98,36 @@ export const VehicleList = observer(() => {
); );
}, },
}, },
{
field: "carrier-name",
headerName: "Перевозчик",
type: "string",
minWidth: 150,
display: "flex",
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,
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: "Город",
@ -93,7 +150,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="Вы уверены?"
@ -104,7 +161,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,6 +22,22 @@ 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);