feat: Add language switcher for preview route

This commit is contained in:
Илья Куприец 2025-05-27 20:57:14 +03:00
parent ede6681c28
commit b837aae711
13 changed files with 1113 additions and 852 deletions

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

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

View File

@ -1,179 +1,231 @@
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";
import { languageStore } from "@/store/LanguageStore";
class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> { class ErrorBoundary extends Component<
state = { hasError: false }; { children: ReactNode },
{ hasError: boolean }
static getDerivedStateFromError() { > {
return { hasError: true }; state = { hasError: false };
}
static getDerivedStateFromError() {
componentDidCatch(error: Error, info: React.ErrorInfo) { return { hasError: true };
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);
}
export function InfiniteCanvas({children} : Readonly<{children?: ReactNode}>) { render() {
const { position, setPosition, scale, setScale, rotation, setRotation, setScreenCenter, screenCenter } = useTransform(); return this.state.hasError ? <p>Whoopsie Daisy!</p> : this.props.children;
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,
setScale,
rotation,
setRotation,
setScreenCenter,
screenCenter,
} = useTransform();
const { routeData, originalRouteData } = useMapData();
useEffect(() => { const applicationRef = useApplication();
const canvas = applicationRef?.app.canvas;
if (!canvas) return;
const canvasRect = canvas.getBoundingClientRect();
const canvasLeft = canvasRect?.left ?? 0;
const canvasTop = canvasRect?.top ?? 0;
const centerX = window.innerWidth / 2 - canvasLeft;
const centerY = window.innerHeight / 2 - canvasTop;
setScreenCenter({x: centerX, y: centerY});
}, [applicationRef?.app.canvas, window.innerWidth, window.innerHeight]);
const handlePointerDown = (e: FederatedMouseEvent) => { const [isDragging, setIsDragging] = useState(false);
setIsDragging(true); const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
setStartPosition({ const [startRotation, setStartRotation] = useState(0);
x: position.x, const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
y: position.y
});
setStartMousePosition({
x: e.globalX,
y: e.globalY
});
setStartRotation(rotation);
e.stopPropagation();
};
// Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута
const [isUserInteracting, setIsUserInteracting] = useState(false);
useEffect(() => { // Реф для отслеживания последнего значения originalRouteData?.rotate
setRotation((originalRouteData?.rotate ?? 0) * Math.PI / 180); const lastOriginalRotation = useRef<number | undefined>(undefined);
}, [originalRouteData?.rotate]);
// Get canvas element and its dimensions/position
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isDragging) return;
if (e.shiftKey) { useEffect(() => {
const center = screenCenter ?? {x: 0, y: 0}; const canvas = applicationRef?.app.canvas;
const startAngle = Math.atan2(startMousePosition.y - center.y, startMousePosition.x - center.x); if (!canvas) return;
const currentAngle = Math.atan2(e.globalY - center.y, e.globalX - center.x);
// Calculate rotation difference in radians const canvasRect = canvas.getBoundingClientRect();
const rotationDiff = currentAngle - startAngle; const canvasLeft = canvasRect.left;
const canvasTop = canvasRect.top;
const centerX = window.innerWidth / 2 - canvasLeft;
const centerY = window.innerHeight / 2 - canvasTop;
setScreenCenter({ x: centerX, y: centerY });
}, [applicationRef?.app.canvas, setScreenCenter]);
// Update rotation const handlePointerDown = (e: FederatedMouseEvent) => {
setRotation(startRotation + rotationDiff); setIsDragging(true);
setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя
setStartPosition({
x: position.x,
y: position.y,
});
setStartMousePosition({
x: e.globalX,
y: e.globalY,
});
setStartRotation(rotation);
e.stopPropagation();
};
const cosDelta = Math.cos(rotationDiff); // Устанавливаем rotation только при изменении originalRouteData и отсутствии взаимодействия пользователя
const sinDelta = Math.sin(rotationDiff); useEffect(() => {
const newRotation = originalRouteData?.rotate ?? 0;
setPosition({
x: 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 {
setRotation(startRotation);
setPosition({
x: startPosition.x - startMousePosition.x + e.globalX,
y: startPosition.y - startMousePosition.y + e.globalY
});
}
e.stopPropagation();
};
// Handle mouse up // Обновляем rotation только если:
const handlePointerUp = (e: FederatedMouseEvent) => { // 1. Пользователь не взаимодействует с канвасом
setIsDragging(false); // 2. Значение действительно изменилось
e.stopPropagation(); if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
}; setRotation((newRotation * Math.PI) / 180);
// Handle mouse wheel for zooming lastOriginalRotation.current = newRotation;
const handleWheel = (e: FederatedWheelEvent) => { }
e.stopPropagation(); }, [originalRouteData?.rotate, isUserInteracting, setRotation]);
// Get mouse position relative to canvas
const mouseX = e.globalX - position.x;
const mouseY = e.globalY - position.y;
// Calculate new scale const handlePointerMove = (e: FederatedMouseEvent) => {
const scaleMin = (routeData?.scale_min ?? 10)/SCALE_FACTOR; if (!isDragging) return;
const scaleMax = (routeData?.scale_max ?? 20)/SCALE_FACTOR;
let zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in if (e.shiftKey) {
//const newScale = scale * zoomFactor; const center = screenCenter ?? { x: 0, y: 0 };
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor)); const startAngle = Math.atan2(
zoomFactor = newScale / scale; startMousePosition.y - center.y,
startMousePosition.x - center.x
);
const currentAngle = Math.atan2(
e.globalY - center.y,
e.globalX - center.x
);
if (scale === newScale) { // Calculate rotation difference in radians
return; const rotationDiff = currentAngle - startAngle;
}
// Update position to zoom towards mouse cursor // Update rotation
setPosition({ setRotation(startRotation + rotationDiff);
x: position.x + mouseX * (1 - zoomFactor),
y: position.y + mouseY * (1 - zoomFactor)
});
setScale(newScale); const cosDelta = Math.cos(rotationDiff);
}; const sinDelta = Math.sin(rotationDiff);
useEffect(() => { setPosition({
applicationRef?.app.render(); x:
console.log(position, scale, rotation); center.x * (1 - cosDelta) +
}, [position, scale, rotation]); startPosition.x * cosDelta +
(center.y - startPosition.y) * sinDelta,
y:
center.y * (1 - cosDelta) +
startPosition.y * cosDelta +
(startPosition.x - center.x) * sinDelta,
});
} else {
setRotation(startRotation);
setPosition({
x: startPosition.x - startMousePosition.x + e.globalX,
y: startPosition.y - startMousePosition.y + e.globalY,
});
}
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => {
return ( setIsDragging(false);
<ErrorBoundary> // Сбрасываем флаг взаимодействия через небольшую задержку
{applicationRef?.app && ( // чтобы избежать немедленного срабатывания useEffect
<pixiGraphics setTimeout(() => {
draw={(g) => { setIsUserInteracting(false);
const canvas = applicationRef.app.canvas; }, 100);
g.clear(); e.stopPropagation();
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) => { const handleWheel = (e: FederatedWheelEvent) => {
g.clear(); e.stopPropagation();
const center = screenCenter ?? {x: 0, y: 0}; setIsUserInteracting(true); // Устанавливаем флаг при зуме
g.circle(center.x, center.y, 1);
g.fill("#fff"); // Get mouse position relative to canvas
}} const mouseX = e.globalX - position.x;
/> */} const mouseY = e.globalY - position.y;
</ErrorBoundary>
); // Calculate new scale
} const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
const actualZoomFactor = newScale / scale;
if (scale === newScale) {
// Сбрасываем флаг, если зум не изменился
setTimeout(() => {
setIsUserInteracting(false);
}, 100);
return;
}
// Update position to zoom towards mouse cursor
setPosition({
x: position.x + mouseX * (1 - actualZoomFactor),
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,33 +1,89 @@
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() {
return ( const navigate = useNavigate();
<Stack direction="column" width="300px" p={2} bgcolor="primary.main"> const navigationType = useNavigationType(); // PUSH, POP, REPLACE
<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 = () => {
<Stack direction="column" alignItems="center" justifyContent="center" my={10} spacing={2}> if (navigationType === "PUSH") {
<Button variant="outlined" color="warning" fullWidth> navigate(-1);
Достопримечательности } else {
</Button> navigate("/route");
<Button variant="outlined" color="warning" fullWidth> }
Остановки };
</Button>
</Stack>
<Stack direction="column" alignItems="center" justifyContent="center" my={10}> return (
<img src={"/GET.png"} alt="logo" width="80%" style={{margin: "0 auto"}}/> <Stack direction="column" width="300px" p={2} bgcolor="primary.main">
</Stack> <button
onClick={handleBack}
<Typography variant="h6" textAlign="center" mt="auto">#ВсемПоПути</Typography> 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
</Stack> direction="column"
); alignItems="center"
} 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
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
variant="h6"
textAlign="center"
mt="auto"
sx={{ color: "#fff" }}
>
#ВсемПоПути
</Typography>
</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,6 +16,9 @@ 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;
@ -57,210 +60,230 @@ const MapDataContext = createContext<{
saveChanges: () => {}, saveChanges: () => {},
}); });
export function MapDataProvider({ export const MapDataProvider = observer(
children, ({ children }: Readonly<{ children: ReactNode }>) => {
}: Readonly<{ children: ReactNode }>) { const { id: routeId } = useParams<{ id: string }>();
const { id: routeId } = useParams<{ id: string }>(); const apiUrl = useApiUrl();
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<StationData[]>(); const [stationData, setStationData] = useState<StationData[]>();
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 { data: routeQuery, isLoading: isRouteLoading } = useCustom({ const [isRouteLoading, setIsRouteLoading] = useState(true);
url: `${apiUrl}/route/${routeId}`, const [isStationLoading, setIsStationLoading] = useState(true);
method: "get", const [isSightLoading, setIsSightLoading] = useState(true);
});
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(() => { useEffect(() => {
// combine changes with original data const fetchData = async () => {
if (originalRouteData) try {
setRouteData({ ...originalRouteData, ...routeChanges }); setIsRouteLoading(true);
if (originalStationData) setStationData(originalStationData); setIsStationLoading(true);
if (originalSightData) setSightData(originalSightData); setIsSightLoading(true);
}, [
originalRouteData,
originalStationData,
originalSightData,
routeChanges,
stationChanges,
sightChanges,
]);
function setScaleRange(min: number, max: number) { const [routeResponse, stationResponse, sightResponse] =
setRouteChanges((prev) => { await Promise.all([
return { ...prev, scale_min: min, scale_max: max }; axiosInstanceForGet.get(`/route/${routeId}`),
}); axiosInstanceForGet.get(`/route/${routeId}/station`),
} axiosInstanceForGet.get(`/route/${routeId}/sight`),
]);
function setMapRotation(rotation: number) { setOriginalRouteData(routeResponse.data as RouteData);
setRouteChanges((prev) => { setOriginalStationData(stationResponse.data as StationData[]);
return { ...prev, rotate: rotation }; setOriginalSightData(sightResponse.data as SightData[]);
});
}
function setMapCenter(x: number, y: number) { setIsRouteLoading(false);
setRouteChanges((prev) => { setIsStationLoading(false);
return { ...prev, center_latitude: x, center_longitude: y }; setIsSightLoading(false);
}); } catch (error) {
} console.error("Error fetching data:", error);
setIsRouteLoading(false);
async function saveChanges() { setIsStationLoading(false);
await axiosInstance.patch(`/route/${routeId}`, routeData); setIsSightLoading(false);
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( fetchData();
sightId: number, }, [routeId, language]);
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) => { useEffect(() => {
if (sight.sight_id === sightId) { // combine changes with original data
return found; if (originalRouteData)
} setRouteData({ ...originalRouteData, ...routeChanges });
return sight; if (originalStationData) setStationData(originalStationData);
}); if (originalSightData) setSightData(originalSightData);
} 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, originalRouteData,
originalStationData, originalStationData,
originalSightData, originalSightData,
routeData, routeChanges,
stationData, stationChanges,
sightData, sightChanges,
isRouteLoading, ]);
isStationLoading,
isSightLoading,
]
);
return ( function setScaleRange(min: number, max: number) {
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider> 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?.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,187 +5,228 @@ 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,
const [minScale, setMinScale] = useState<number>(1); setScaleRange,
const [maxScale, setMaxScale] = useState<number>(10); saveChanges,
const [localCenter, setLocalCenter] = useState<{x: number, y: number}>({x: 0, y: 0}); originalRouteData,
const [rotationDegrees, setRotationDegrees] = useState<number>(0); setMapRotation,
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({x: originalRouteData.center_latitude ?? 0, y: originalRouteData.center_longitude ?? 0}); setLocalCenter({
} x: originalRouteData.center_latitude ?? 0,
}, [originalRouteData]); y: originalRouteData.center_longitude ?? 0,
});
}
}, [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(() => {
setRotationDegrees((Math.round(rotation * 180 / Math.PI) % 360 + 360) % 360); const center = screenCenter ?? { x: 0, y: 0 };
}, [rotation]); const localCenter = screenToLocal(center.x, center.y);
useEffect(() => { const coordinates = localToCoordinates(localCenter.x, localCenter.y);
setMapRotation(rotationDegrees); setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
}, [rotationDegrees]); }, [position]);
useEffect(() => { useEffect(() => {
const center = screenCenter ?? {x: 0, y: 0}; setMapCenter(localCenter.x, localCenter.y);
const localCenter = screenToLocal(center.x, center.y); }, [localCenter]);
const coordinates = localToCoordinates(localCenter.x, localCenter.y);
setLocalCenter({x: coordinates.latitude, y: coordinates.longitude});
}, [position]);
function setRotationFromDegrees(degrees: number) {
rotateToAngle((degrees * Math.PI) / 180);
}
useEffect(() => { function pan({ x, y }: { x: number; y: number }) {
setMapCenter(localCenter.x, localCenter.y); const coordinates = coordinatesToLocal(x, y);
}, [localCenter]); setTransform(coordinates.x, coordinates.y);
}
function setRotationFromDegrees(degrees: number) { if (!routeData) {
rotateToAngle(degrees * Math.PI / 180); console.error("routeData is null");
} return null;
}
function pan({x, y}: {x: number, y: number}) { return (
const coordinates = coordinatesToLocal(x,y); <Stack
setTransform(coordinates.x, coordinates.y); position="absolute"
} 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>
if(!routeData) { <Stack spacing={2} direction="row" alignItems="center">
console.error("routeData is null"); <TextField
return null; type="number"
} 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>
return ( <TextField
<Stack type="number"
position="absolute" right={8} top={8} bottom={8} p={2} label="Поворот (в градусах)"
gap={1} variant="filled"
minWidth="400px" bgcolor="primary.main" value={rotationDegrees}
border="1px solid #e0e0e0" borderRadius={2} onChange={(e) => {
> const value = Number(e.target.value);
<Typography variant="h6" sx={{ mb: 2 }} textAlign="center"> if (!isNaN(value)) {
Детали о достопримечательностях 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 spacing={2} direction="row" alignItems="center"> <Stack direction="row" spacing={2}>
<TextField <TextField
type="number" type="number"
label="Минимальный масштаб" label="Центр карты, широта"
variant="filled" variant="filled"
value={minScale} value={Math.round(localCenter.x * 100000) / 100000}
onChange={(e) => setMinScale(Number(e.target.value))} onChange={(e) => {
style={{backgroundColor: "#222", borderRadius: 4}} setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) }));
sx={{ pan({ x: Number(e.target.value), y: localCenter.y });
'& .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",
}} },
/> }}
<TextField />
type="number" <TextField
label="Максимальный масштаб" type="number"
variant="filled" label="Центр карты, высота"
value={maxScale} variant="filled"
onChange={(e) => setMaxScale(Number(e.target.value))} value={Math.round(localCenter.y * 100000) / 100000}
style={{backgroundColor: "#222", borderRadius: 4}} onChange={(e) => {
sx={{ setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) }));
'& .MuiInputLabel-root.Mui-focused': { pan({ x: localCenter.x, y: Number(e.target.value) });
color: "#fff" }}
} style={{ backgroundColor: "#222", borderRadius: 4 }}
}} sx={{
slotProps={{ "& .MuiInputLabel-root": {
input: { color: "#fff",
min: 0.1 },
} "& .MuiInputBase-input": {
}} color: "#fff",
/> },
</Stack> }}
/>
</Stack>
<TextField <Button
type="number" variant="contained"
label="Поворот (в градусах)" color="secondary"
variant="filled" sx={{ mt: 2 }}
value={rotationDegrees} onClick={() => {
onChange={(e) => { saveChanges();
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,150 +1,204 @@
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,
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 }>();
function screenToLocal(screenX: number, screenY: number) { const screenToLocal = useCallback(
// Translate point relative to current pan position (screenX: number, screenY: number) => {
const translatedX = (screenX - position.x) / scale; // Translate point relative to current pan position
const translatedY = (screenY - position.y) / scale; const translatedX = (screenX - position.x) / 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
function localToScreen(localX: number, localY: number) { const localToScreen = useCallback(
(localX: number, localY: number) => {
const upscaledX = localX * UP_SCALE;
const upscaledY = localY * UP_SCALE;
const upscaledX = localX * UP_SCALE; const cosRotation = Math.cos(rotation);
const upscaledY = localY * UP_SCALE; const sinRotation = Math.sin(rotation);
const rotatedX = upscaledX * cosRotation - upscaledY * sinRotation;
const rotatedY = upscaledX * sinRotation + upscaledY * cosRotation;
const cosRotation = Math.cos(rotation); const translatedX = rotatedX * scale + position.x;
const sinRotation = Math.sin(rotation); const translatedY = rotatedY * scale + position.y;
const rotatedX = upscaledX * cosRotation - upscaledY * sinRotation;
const rotatedY = upscaledX * sinRotation + upscaledY * cosRotation;
const translatedX = rotatedX*scale + position.x; return {
const translatedY = rotatedY*scale + position.y; x: translatedX,
y: translatedY,
};
},
[position.x, position.y, scale, rotation]
);
return { const rotateToAngle = useCallback(
x: translatedX, (to: number, fromPosition?: { x: number; y: number }) => {
y: translatedY const rotationDiff = to - rotation;
};
}
const center = screenCenter ?? { x: 0, y: 0 };
const cosDelta = Math.cos(rotationDiff);
const sinDelta = Math.sin(rotationDiff);
function rotateToAngle(to: number, fromPosition?: {x: number, y: number}) { const currentFromPosition = fromPosition ?? position;
setRotation(to);
const rotationDiff = to - rotation;
const center = screenCenter ?? {x: 0, y: 0};
const cosDelta = Math.cos(rotationDiff);
const sinDelta = Math.sin(rotationDiff);
fromPosition ??= position; const newPosition = {
x:
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,
};
setPosition({ // Update both rotation and position in a single batch to avoid stale closure
x: center.x * (1 - cosDelta) + fromPosition.x * cosDelta + (center.y - fromPosition.y) * sinDelta, setRotation(to);
y: center.y * (1 - cosDelta) + fromPosition.y * cosDelta + (fromPosition.x - center.x) * sinDelta setPosition(newPosition);
}); },
} [rotation, position, screenCenter]
);
function setTransform(latitude: number, longitude: number, rotationDegrees?: number, useScale ?: number) { const setTransform = useCallback(
const selectedRotation = rotationDegrees ? (rotationDegrees * Math.PI / 180) : rotation; (
const selectedScale = useScale ? useScale/SCALE_FACTOR : scale; latitude: number,
const center = screenCenter ?? {x: 0, y: 0}; longitude: number,
console.log("center", center.x, center.y); rotationDegrees?: number,
const newPosition = { useScale?: number
x: -latitude * UP_SCALE * selectedScale, ) => {
y: -longitude * UP_SCALE * selectedScale const selectedRotation =
}; rotationDegrees !== undefined
? (rotationDegrees * Math.PI) / 180
: rotation;
const selectedScale =
useScale !== undefined ? useScale / SCALE_FACTOR : scale;
const center = screenCenter ?? { x: 0, y: 0 };
const cos = Math.cos(selectedRotation); console.log("center", center.x, center.y);
const sin = Math.sin(selectedRotation);
// Translate point relative to center, rotate, then translate back
const dx = newPosition.x;
const dy = newPosition.y;
newPosition.x = (dx * cos - dy * sin) + center.x;
newPosition.y = (dx * sin + dy * cos) + center.y;
const newPosition = {
setPosition(newPosition); x: -latitude * UP_SCALE * selectedScale,
setRotation(selectedRotation); y: -longitude * UP_SCALE * selectedScale,
setScale(selectedScale); };
}
const value = useMemo(() => ({ const cosRot = Math.cos(selectedRotation);
position, const sinRot = Math.sin(selectedRotation);
scale,
rotation,
screenCenter,
setPosition,
setScale,
setRotation,
rotateToAngle,
screenToLocal,
localToScreen,
setTransform,
setScreenCenter
}), [position, scale, rotation, screenCenter]);
return ( // Translate point relative to center, rotate, then translate back
<TransformContext.Provider value={value}> const dx = newPosition.x;
{children} const dy = newPosition.y;
</TransformContext.Provider> newPosition.x = dx * cosRot - dy * sinRot + center.x;
); newPosition.y = dx * sinRot + dy * cosRot + center.y;
// Batch state updates to avoid intermediate renders
setPosition(newPosition);
setRotation(selectedRotation);
setScale(selectedScale);
},
[rotation, scale, screenCenter]
);
const value = useMemo(
() => ({
position,
scale,
rotation,
screenCenter,
setPosition,
setScale,
setRotation,
rotateToAngle,
screenToLocal,
localToScreen,
setTransform,
setScreenCenter,
}),
[
position,
scale,
rotation,
screenCenter,
rotateToAngle,
screenToLocal,
localToScreen,
setTransform,
]
);
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,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) => {
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]
);
const draw = useCallback((g: Graphics) => { if (points.length === 0) {
g.clear(); console.error("points is empty");
const coordStart = coordinatesToLocal(points[0].x, points[0].y); return null;
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) { return <pixiGraphics draw={draw} />;
console.error("points is empty"); }
return null;
}
return (
<pixiGraphics
draw={draw}
/>
);
}

View File

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

View File

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

@ -393,18 +393,18 @@ export const RouteEdit = observer(() => {
parentResource="route" parentResource="route"
childResource="station" childResource="station"
fields={stationFields} fields={stationFields}
title="станции" title="остановки"
dragAllowed={true} dragAllowed={true}
/> />
<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,10 +103,9 @@ 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"
onClick={() => navigate(`/route-preview/${id}`)} onClick={() => navigate(`/route-preview/${id}`)}
> >

View File

@ -4,6 +4,7 @@ import { TOKEN_KEY } from "@providers";
import axios from "axios"; import axios from "axios";
import { languageStore } from "@stores"; 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 +23,19 @@ axiosInstance.interceptors.request.use((config) => {
return config; return config;
}); });
export const axiosInstanceForGet = axios.create({
baseURL: import.meta.env.VITE_KRBL_API,
});
axiosInstanceForGet.interceptors.request.use((config) => {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
config.headers["X-Language"] = languageStore.language;
return config;
});
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);