feat: Move route-preview
This commit is contained in:
		
							
								
								
									
										9
									
								
								src/pages/Route/route-preview/Constants.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/pages/Route/route-preview/Constants.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| export const UP_SCALE = 30000; | ||||
| export const PATH_WIDTH = 15; | ||||
| export const STATION_RADIUS = 20; | ||||
| export const STATION_OUTLINE_WIDTH = 10; | ||||
| export const SIGHT_SIZE = 60; | ||||
| export const SCALE_FACTOR = 50; | ||||
|  | ||||
| export const BACKGROUND_COLOR = 0x111111; | ||||
| export const PATH_COLOR = 0xff4d4d; | ||||
							
								
								
									
										230
									
								
								src/pages/Route/route-preview/InfiniteCanvas.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										230
									
								
								src/pages/Route/route-preview/InfiniteCanvas.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,230 @@ | ||||
| import { FederatedMouseEvent, FederatedWheelEvent } from "pixi.js"; | ||||
| import { Component, ReactNode, useEffect, useState, useRef } from "react"; | ||||
| import { useTransform } from "./TransformContext"; | ||||
| import { useMapData } from "./MapDataContext"; | ||||
| import { SCALE_FACTOR } from "./Constants"; | ||||
| import { useApplication } from "@pixi/react"; | ||||
|  | ||||
| class ErrorBoundary extends Component< | ||||
|   { children: ReactNode }, | ||||
|   { hasError: boolean } | ||||
| > { | ||||
|   state = { hasError: false }; | ||||
|  | ||||
|   static getDerivedStateFromError() { | ||||
|     return { hasError: true }; | ||||
|   } | ||||
|  | ||||
|   componentDidCatch(error: Error, info: React.ErrorInfo) { | ||||
|     console.error("Error caught:", error, info); | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     return this.state.hasError ? <p>Whoopsie Daisy!</p> : this.props.children; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function InfiniteCanvas({ | ||||
|   children, | ||||
| }: Readonly<{ children?: ReactNode }>) { | ||||
|   const { | ||||
|     position, | ||||
|     setPosition, | ||||
|     scale, | ||||
|     setScale, | ||||
|     rotation, | ||||
|     setRotation, | ||||
|     setScreenCenter, | ||||
|     screenCenter, | ||||
|   } = useTransform(); | ||||
|   const { routeData, originalRouteData } = useMapData(); | ||||
|  | ||||
|   const applicationRef = useApplication(); | ||||
|  | ||||
|   const [isDragging, setIsDragging] = useState(false); | ||||
|   const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 }); | ||||
|   const [startRotation, setStartRotation] = useState(0); | ||||
|   const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); | ||||
|  | ||||
|   // Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута | ||||
|   const [isUserInteracting, setIsUserInteracting] = useState(false); | ||||
|  | ||||
|   // Реф для отслеживания последнего значения originalRouteData?.rotate | ||||
|   const lastOriginalRotation = useRef<number | undefined>(undefined); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const canvas = applicationRef?.app.canvas; | ||||
|     if (!canvas) return; | ||||
|  | ||||
|     const canvasRect = canvas.getBoundingClientRect(); | ||||
|     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]); | ||||
|  | ||||
|   const handlePointerDown = (e: FederatedMouseEvent) => { | ||||
|     setIsDragging(true); | ||||
|     setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя | ||||
|     setStartPosition({ | ||||
|       x: position.x, | ||||
|       y: position.y, | ||||
|     }); | ||||
|     setStartMousePosition({ | ||||
|       x: e.globalX, | ||||
|       y: e.globalY, | ||||
|     }); | ||||
|     setStartRotation(rotation); | ||||
|     e.stopPropagation(); | ||||
|   }; | ||||
|  | ||||
|   // Устанавливаем rotation только при изменении originalRouteData и отсутствии взаимодействия пользователя | ||||
|   useEffect(() => { | ||||
|     const newRotation = originalRouteData?.rotate ?? 0; | ||||
|  | ||||
|     // Обновляем rotation только если: | ||||
|     // 1. Пользователь не взаимодействует с канвасом | ||||
|     // 2. Значение действительно изменилось | ||||
|     if (!isUserInteracting && lastOriginalRotation.current !== newRotation) { | ||||
|       setRotation((newRotation * Math.PI) / 180); | ||||
|       lastOriginalRotation.current = newRotation; | ||||
|     } | ||||
|   }, [originalRouteData?.rotate, isUserInteracting, setRotation]); | ||||
|  | ||||
|   const handlePointerMove = (e: FederatedMouseEvent) => { | ||||
|     if (!isDragging) return; | ||||
|  | ||||
|     if (e.shiftKey) { | ||||
|       const center = screenCenter ?? { x: 0, y: 0 }; | ||||
|       const startAngle = Math.atan2( | ||||
|         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 | ||||
|       const rotationDiff = currentAngle - startAngle; | ||||
|  | ||||
|       // Update rotation | ||||
|       setRotation(startRotation + rotationDiff); | ||||
|  | ||||
|       const cosDelta = Math.cos(rotationDiff); | ||||
|       const sinDelta = Math.sin(rotationDiff); | ||||
|  | ||||
|       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(); | ||||
|   }; | ||||
|  | ||||
|   const handlePointerUp = (e: FederatedMouseEvent) => { | ||||
|     setIsDragging(false); | ||||
|     // Сбрасываем флаг взаимодействия через небольшую задержку | ||||
|     // чтобы избежать немедленного срабатывания useEffect | ||||
|     setTimeout(() => { | ||||
|       setIsUserInteracting(false); | ||||
|     }, 100); | ||||
|     e.stopPropagation(); | ||||
|   }; | ||||
|  | ||||
|   const handleWheel = (e: FederatedWheelEvent) => { | ||||
|     e.stopPropagation(); | ||||
|     setIsUserInteracting(true); // Устанавливаем флаг при зуме | ||||
|  | ||||
|     // Get mouse position relative to canvas | ||||
|     const mouseX = e.globalX - position.x; | ||||
|     const mouseY = e.globalY - position.y; | ||||
|  | ||||
|     // 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> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										89
									
								
								src/pages/Route/route-preview/LeftSidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/pages/Route/route-preview/LeftSidebar.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | ||||
| import { Stack, Typography, Button } from "@mui/material"; | ||||
|  | ||||
| import { useNavigate, useNavigationType } from "react-router"; | ||||
|  | ||||
| export function LeftSidebar() { | ||||
|   const navigate = useNavigate(); | ||||
|   const navigationType = useNavigationType(); // PUSH, POP, REPLACE | ||||
|  | ||||
|   const handleBack = () => { | ||||
|     if (navigationType === "PUSH") { | ||||
|       navigate(-1); | ||||
|     } else { | ||||
|       navigate("/route"); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Stack direction="column" width="300px" p={2} bgcolor="primary.main"> | ||||
|       <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} /> | ||||
|         <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> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										307
									
								
								src/pages/Route/route-preview/MapDataContext.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										307
									
								
								src/pages/Route/route-preview/MapDataContext.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,307 @@ | ||||
| import { useParams } from "react-router"; | ||||
| import { authInstance, languageInstance } from "@shared"; | ||||
| import { | ||||
|   createContext, | ||||
|   ReactNode, | ||||
|   useContext, | ||||
|   useEffect, | ||||
|   useMemo, | ||||
|   useState, | ||||
| } from "react"; | ||||
| import { | ||||
|   RouteData, | ||||
|   SightData, | ||||
|   SightPatchData, | ||||
|   StationData, | ||||
|   StationPatchData, | ||||
| } from "./types"; | ||||
|  | ||||
| import { observer } from "mobx-react-lite"; | ||||
|  | ||||
| const MapDataContext = createContext<{ | ||||
|   originalRouteData?: RouteData; | ||||
|   originalStationData?: StationData[]; | ||||
|   originalSightData?: SightData[]; | ||||
|   routeData?: RouteData; | ||||
|   stationData?: StationDataWithLanguage; | ||||
|   sightData?: SightData[]; | ||||
|  | ||||
|   isRouteLoading: boolean; | ||||
|   isStationLoading: boolean; | ||||
|   isSightLoading: boolean; | ||||
|   setScaleRange: (min: number, max: number) => void; | ||||
|   setMapRotation: (rotation: number) => void; | ||||
|   setMapCenter: (x: number, y: number) => void; | ||||
|   setStationOffset: (stationId: number, x: number, y: number) => void; | ||||
|   setSightCoordinates: ( | ||||
|     sightId: number, | ||||
|     latitude: number, | ||||
|     longitude: number | ||||
|   ) => void; | ||||
|   saveChanges: () => void; | ||||
| }>({ | ||||
|   originalRouteData: undefined, | ||||
|   originalStationData: undefined, | ||||
|   originalSightData: undefined, | ||||
|   routeData: undefined, | ||||
|   stationData: undefined, | ||||
|   sightData: undefined, | ||||
|  | ||||
|   isRouteLoading: true, | ||||
|   isStationLoading: true, | ||||
|   isSightLoading: true, | ||||
|   setScaleRange: () => {}, | ||||
|   setMapRotation: () => {}, | ||||
|   setMapCenter: () => {}, | ||||
|   setStationOffset: () => {}, | ||||
|   setSightCoordinates: () => {}, | ||||
|   saveChanges: () => {}, | ||||
| }); | ||||
|  | ||||
| type StationDataWithLanguage = { | ||||
|   [key: string]: StationData[]; | ||||
| }; | ||||
| export const MapDataProvider = observer( | ||||
|   ({ children }: Readonly<{ children: ReactNode }>) => { | ||||
|     const { id: routeId } = useParams<{ id: string }>(); | ||||
|  | ||||
|     const [originalRouteData, setOriginalRouteData] = useState<RouteData>(); | ||||
|     const [originalStationData, setOriginalStationData] = | ||||
|       useState<StationData[]>(); | ||||
|     const [originalSightData, setOriginalSightData] = useState<SightData[]>(); | ||||
|  | ||||
|     const [routeData, setRouteData] = useState<RouteData>(); | ||||
|     const [stationData, setStationData] = useState<StationDataWithLanguage>({ | ||||
|       RU: [], | ||||
|       EN: [], | ||||
|       ZH: [], | ||||
|     }); | ||||
|     const [sightData, setSightData] = useState<SightData[]>(); | ||||
|  | ||||
|     const [routeChanges, setRouteChanges] = useState<Partial<RouteData>>({}); | ||||
|     const [stationChanges, setStationChanges] = useState<StationPatchData[]>( | ||||
|       [] | ||||
|     ); | ||||
|     const [sightChanges, setSightChanges] = useState<SightPatchData[]>([]); | ||||
|  | ||||
|     const [isRouteLoading, setIsRouteLoading] = useState(true); | ||||
|     const [isStationLoading, setIsStationLoading] = useState(true); | ||||
|     const [isSightLoading, setIsSightLoading] = useState(true); | ||||
|  | ||||
|     useEffect(() => { | ||||
|       const fetchData = async () => { | ||||
|         try { | ||||
|           setIsRouteLoading(true); | ||||
|           setIsStationLoading(true); | ||||
|           setIsSightLoading(true); | ||||
|  | ||||
|           const [ | ||||
|             routeResponse, | ||||
|             ruStationResponse, | ||||
|             enStationResponse, | ||||
|             zhStationResponse, | ||||
|             sightResponse, | ||||
|           ] = await Promise.all([ | ||||
|             authInstance.get(`/route/${routeId}`), | ||||
|             languageInstance("ru").get(`/route/${routeId}/station`), | ||||
|             languageInstance("en").get(`/route/${routeId}/station`), | ||||
|             languageInstance("zh").get(`/route/${routeId}/station`), | ||||
|             authInstance.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 as unknown as SightData[]); | ||||
|  | ||||
|           setIsRouteLoading(false); | ||||
|           setIsStationLoading(false); | ||||
|           setIsSightLoading(false); | ||||
|         } catch (error) { | ||||
|           console.error("Error fetching data:", error); | ||||
|           setIsRouteLoading(false); | ||||
|           setIsStationLoading(false); | ||||
|           setIsSightLoading(false); | ||||
|         } | ||||
|       }; | ||||
|  | ||||
|       fetchData(); | ||||
|     }, [routeId]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|       // combine changes with original data | ||||
|       if (originalRouteData) | ||||
|         setRouteData({ ...originalRouteData, ...routeChanges }); | ||||
|       if (originalSightData) setSightData(originalSightData); | ||||
|     }, [ | ||||
|       originalRouteData, | ||||
|       originalSightData, | ||||
|       routeChanges, | ||||
|       stationChanges, | ||||
|       sightChanges, | ||||
|     ]); | ||||
|  | ||||
|     function setScaleRange(min: number, max: number) { | ||||
|       setRouteChanges((prev) => { | ||||
|         return { ...prev, scale_min: min, scale_max: max }; | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     function setMapRotation(rotation: number) { | ||||
|       setRouteChanges((prev) => { | ||||
|         return { ...prev, rotate: rotation }; | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     function setMapCenter(x: number, y: number) { | ||||
|       setRouteChanges((prev) => { | ||||
|         return { ...prev, center_latitude: x, center_longitude: y }; | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     async function saveChanges() { | ||||
|       await authInstance.patch(`/route/${routeId}`, routeData); | ||||
|       await saveStationChanges(); | ||||
|       await saveSightChanges(); | ||||
|     } | ||||
|  | ||||
|     async function saveStationChanges() { | ||||
|       for (const station of stationChanges) { | ||||
|         const response = await authInstance.patch( | ||||
|           `/route/${routeId}/station`, | ||||
|           station | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     async function saveSightChanges() { | ||||
|       console.log("sightChanges", sightChanges); | ||||
|       for (const sight of sightChanges) { | ||||
|         const response = await authInstance.patch( | ||||
|           `/route/${routeId}/sight`, | ||||
|           sight | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     function setStationOffset(stationId: number, x: number, y: number) { | ||||
|       setStationChanges((prev) => { | ||||
|         let found = prev.find((station) => station.station_id === stationId); | ||||
|         if (found) { | ||||
|           found.offset_x = x; | ||||
|           found.offset_y = y; | ||||
|  | ||||
|           return prev.map((station) => { | ||||
|             if (station.station_id === stationId) { | ||||
|               return found; | ||||
|             } | ||||
|             return station; | ||||
|           }); | ||||
|         } else { | ||||
|           const foundStation = stationData.ru?.find( | ||||
|             (station) => station.id === stationId | ||||
|           ); | ||||
|           if (foundStation) { | ||||
|             return [ | ||||
|               ...prev, | ||||
|               { | ||||
|                 station_id: stationId, | ||||
|                 offset_x: x, | ||||
|                 offset_y: y, | ||||
|                 transfers: foundStation.transfers, | ||||
|               }, | ||||
|             ]; | ||||
|           } | ||||
|           return prev; | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     function setSightCoordinates( | ||||
|       sightId: number, | ||||
|       latitude: number, | ||||
|       longitude: number | ||||
|     ) { | ||||
|       setSightChanges((prev) => { | ||||
|         let found = prev.find((sight) => sight.sight_id === sightId); | ||||
|         if (found) { | ||||
|           found.latitude = latitude; | ||||
|           found.longitude = longitude; | ||||
|  | ||||
|           return prev.map((sight) => { | ||||
|             if (sight.sight_id === sightId) { | ||||
|               return found; | ||||
|             } | ||||
|             return sight; | ||||
|           }); | ||||
|         } else { | ||||
|           const foundSight = sightData?.find((sight) => sight.id === sightId); | ||||
|           if (foundSight) { | ||||
|             return [ | ||||
|               ...prev, | ||||
|               { | ||||
|                 sight_id: sightId, | ||||
|                 latitude, | ||||
|                 longitude, | ||||
|               }, | ||||
|             ]; | ||||
|           } | ||||
|           return prev; | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     useEffect(() => { | ||||
|       console.log("sightChanges", sightChanges); | ||||
|     }, [sightChanges]); | ||||
|  | ||||
|     const value = useMemo( | ||||
|       () => ({ | ||||
|         originalRouteData, | ||||
|         originalStationData, | ||||
|         originalSightData, | ||||
|         routeData, | ||||
|         stationData, | ||||
|         sightData, | ||||
|         isRouteLoading, | ||||
|         isStationLoading, | ||||
|         isSightLoading, | ||||
|         setScaleRange, | ||||
|         setMapRotation, | ||||
|         setMapCenter, | ||||
|         saveChanges, | ||||
|         setStationOffset, | ||||
|         setSightCoordinates, | ||||
|       }), | ||||
|       [ | ||||
|         originalRouteData, | ||||
|         originalStationData, | ||||
|         originalSightData, | ||||
|         routeData, | ||||
|         stationData, | ||||
|         sightData, | ||||
|         isRouteLoading, | ||||
|         isStationLoading, | ||||
|         isSightLoading, | ||||
|       ] | ||||
|     ); | ||||
|  | ||||
|     return ( | ||||
|       <MapDataContext.Provider value={value}> | ||||
|         {children} | ||||
|       </MapDataContext.Provider> | ||||
|     ); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| export const useMapData = () => { | ||||
|   const context = useContext(MapDataContext); | ||||
|   if (!context) { | ||||
|     throw new Error("useMapData must be used within a MapDataProvider"); | ||||
|   } | ||||
|   return context; | ||||
| }; | ||||
							
								
								
									
										232
									
								
								src/pages/Route/route-preview/RightSidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										232
									
								
								src/pages/Route/route-preview/RightSidebar.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,232 @@ | ||||
| import { Button, Stack, TextField, Typography } from "@mui/material"; | ||||
| import { useMapData } from "./MapDataContext"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { useTransform } from "./TransformContext"; | ||||
| import { coordinatesToLocal, localToCoordinates } from "./utils"; | ||||
|  | ||||
| export function RightSidebar() { | ||||
|   const { | ||||
|     routeData, | ||||
|     setScaleRange, | ||||
|     saveChanges, | ||||
|     originalRouteData, | ||||
|     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(() => { | ||||
|     if (originalRouteData) { | ||||
|       setMinScale(originalRouteData.scale_min ?? 1); | ||||
|       setMaxScale(originalRouteData.scale_max ?? 10); | ||||
|       setRotationDegrees(originalRouteData.rotate ?? 0); | ||||
|       setLocalCenter({ | ||||
|         x: originalRouteData.center_latitude ?? 0, | ||||
|         y: originalRouteData.center_longitude ?? 0, | ||||
|       }); | ||||
|     } | ||||
|   }, [originalRouteData]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (minScale && maxScale) { | ||||
|       setScaleRange(minScale, maxScale); | ||||
|     } | ||||
|   }, [minScale, maxScale]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setRotationDegrees( | ||||
|       ((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360 | ||||
|     ); | ||||
|   }, [rotation]); | ||||
|   useEffect(() => { | ||||
|     setMapRotation(rotationDegrees); | ||||
|   }, [rotationDegrees]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const center = screenCenter ?? { x: 0, y: 0 }; | ||||
|     const localCenter = screenToLocal(center.x, center.y); | ||||
|     const coordinates = localToCoordinates(localCenter.x, localCenter.y); | ||||
|     setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude }); | ||||
|   }, [position]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setMapCenter(localCenter.x, localCenter.y); | ||||
|   }, [localCenter]); | ||||
|  | ||||
|   function setRotationFromDegrees(degrees: number) { | ||||
|     rotateToAngle((degrees * Math.PI) / 180); | ||||
|   } | ||||
|  | ||||
|   function pan({ x, y }: { x: number; y: number }) { | ||||
|     const coordinates = coordinatesToLocal(x, y); | ||||
|     setTransform(coordinates.x, coordinates.y); | ||||
|   } | ||||
|  | ||||
|   if (!routeData) { | ||||
|     console.error("routeData is null"); | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Stack | ||||
|       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> | ||||
|  | ||||
|       <Stack spacing={2} direction="row" alignItems="center"> | ||||
|         <TextField | ||||
|           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> | ||||
|  | ||||
|       <TextField | ||||
|         type="number" | ||||
|         label="Поворот (в градусах)" | ||||
|         variant="filled" | ||||
|         value={rotationDegrees} | ||||
|         onChange={(e) => { | ||||
|           const value = Number(e.target.value); | ||||
|           if (!isNaN(value)) { | ||||
|             setRotationFromDegrees(value); | ||||
|           } | ||||
|         }} | ||||
|         onKeyDown={(e) => { | ||||
|           if (e.key === "Enter") { | ||||
|             e.currentTarget.blur(); | ||||
|           } | ||||
|         }} | ||||
|         style={{ backgroundColor: "#222", borderRadius: 4 }} | ||||
|         sx={{ | ||||
|           "& .MuiInputLabel-root": { | ||||
|             color: "#fff", | ||||
|           }, | ||||
|           "& .MuiInputBase-input": { | ||||
|             color: "#fff", | ||||
|           }, | ||||
|         }} | ||||
|         slotProps={{ | ||||
|           input: { | ||||
|             min: 0, | ||||
|             max: 360, | ||||
|           }, | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <Stack direction="row" spacing={2}> | ||||
|         <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": { | ||||
|               color: "#fff", | ||||
|             }, | ||||
|             "& .MuiInputBase-input": { | ||||
|               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": { | ||||
|               color: "#fff", | ||||
|             }, | ||||
|             "& .MuiInputBase-input": { | ||||
|               color: "#fff", | ||||
|             }, | ||||
|           }} | ||||
|         /> | ||||
|       </Stack> | ||||
|  | ||||
|       <Button | ||||
|         variant="contained" | ||||
|         color="secondary" | ||||
|         sx={{ mt: 2 }} | ||||
|         onClick={() => { | ||||
|           saveChanges(); | ||||
|         }} | ||||
|       > | ||||
|         Сохранить изменения | ||||
|       </Button> | ||||
|     </Stack> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										119
									
								
								src/pages/Route/route-preview/Sight.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								src/pages/Route/route-preview/Sight.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,119 @@ | ||||
| import { useEffect, useState } from "react"; | ||||
| import { useTransform } from "./TransformContext"; | ||||
| import { SightData } from "./types"; | ||||
| import { Assets, FederatedMouseEvent, Graphics, Texture } from "pixi.js"; | ||||
| import { COLORS } from "../../contexts/color-mode/theme"; | ||||
| import { SIGHT_SIZE, UP_SCALE } from "./Constants"; | ||||
| import { coordinatesToLocal, localToCoordinates } from "./utils"; | ||||
| import { useMapData } from "./MapDataContext"; | ||||
|  | ||||
| interface SightProps { | ||||
| 	sight: SightData; | ||||
| 	id: number; | ||||
| } | ||||
|  | ||||
| export function Sight({ | ||||
| 	sight, id | ||||
| }: Readonly<SightProps>) { | ||||
| 	const { rotation, scale } = useTransform(); | ||||
| 	const { setSightCoordinates } = useMapData(); | ||||
|  | ||||
| 	const [position, setPosition] = useState(coordinatesToLocal(sight.latitude, sight.longitude)); | ||||
| 	const [isDragging, setIsDragging] = useState(false); | ||||
|     const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); | ||||
|     const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 }); | ||||
|  | ||||
|     const handlePointerDown = (e: FederatedMouseEvent) => { | ||||
|         setIsDragging(true); | ||||
|         setStartPosition({ | ||||
|             x: position.x, | ||||
|             y: position.y | ||||
|         }); | ||||
| 		setStartMousePosition({ | ||||
| 			x: e.globalX, | ||||
| 			y: e.globalY | ||||
| 		}); | ||||
| 		 | ||||
|         e.stopPropagation(); | ||||
|     }; | ||||
|     const handlePointerMove = (e: FederatedMouseEvent) => { | ||||
|         if (!isDragging) return; | ||||
| 		const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE; | ||||
| 		const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE; | ||||
| 		const cos = Math.cos(rotation); | ||||
| 		const sin = Math.sin(rotation); | ||||
| 		const newPosition = { | ||||
| 			x: startPosition.x + dx * cos + dy * sin, | ||||
| 			y: startPosition.y - dx * sin + dy * cos | ||||
| 		}; | ||||
| 		setPosition(newPosition); | ||||
| 		const coordinates = localToCoordinates(newPosition.x, newPosition.y); | ||||
| 		setSightCoordinates(sight.id, coordinates.latitude, coordinates.longitude); | ||||
|         e.stopPropagation(); | ||||
|     }; | ||||
|  | ||||
|     const handlePointerUp = (e: FederatedMouseEvent) => { | ||||
|         setIsDragging(false); | ||||
|         e.stopPropagation(); | ||||
|     }; | ||||
|  | ||||
| 	const [texture, setTexture] = useState(Texture.EMPTY); | ||||
| 	useEffect(() => { | ||||
|         if (texture === Texture.EMPTY) { | ||||
|             Assets | ||||
|                 .load('/SightIcon.png') | ||||
|                 .then((result) => { | ||||
|                     setTexture(result) | ||||
|                 }); | ||||
|         } | ||||
|     }, [texture]); | ||||
|  | ||||
| 	function draw(g: Graphics) { | ||||
| 		g.clear(); | ||||
| 		g.circle(0, 0, 20); | ||||
| 		g.fill({color: COLORS.primary}); // Fill circle with primary color | ||||
| 	} | ||||
|  | ||||
| 	if(!sight) { | ||||
| 		console.error("sight is null"); | ||||
| 		return null; | ||||
| 	} | ||||
|  | ||||
| 	const coordinates = coordinatesToLocal(sight.latitude, sight.longitude); | ||||
|  | ||||
| 	return ( | ||||
| 		<pixiContainer rotation={-rotation} | ||||
| 			eventMode='static' | ||||
| 			interactive | ||||
| 			onPointerDown={handlePointerDown} | ||||
| 			onGlobalPointerMove={handlePointerMove} | ||||
| 			onPointerUp={handlePointerUp} | ||||
| 			onPointerUpOutside={handlePointerUp} | ||||
| 			x={position.x * UP_SCALE - SIGHT_SIZE/2} // Offset by half width to center | ||||
| 			y={position.y * UP_SCALE - SIGHT_SIZE/2} // Offset by half height to center | ||||
| 		> | ||||
| 			<pixiSprite | ||||
| 				texture={texture}  | ||||
| 				width={SIGHT_SIZE} | ||||
| 				height={SIGHT_SIZE} | ||||
| 			/> | ||||
| 			<pixiGraphics | ||||
| 				draw={draw} | ||||
| 				x={SIGHT_SIZE} | ||||
| 				y={0} | ||||
| 			/> | ||||
| 			<pixiText | ||||
| 				text={`${id+1}`} | ||||
| 				x={SIGHT_SIZE+1} | ||||
| 				y={0} | ||||
| 				anchor={0.5} | ||||
| 				 | ||||
| 				style={{ | ||||
| 					fontSize: 24, | ||||
| 					fontWeight: 'bold', | ||||
| 					fill: "#ffffff", | ||||
| 				}} | ||||
| 			/> | ||||
| 		</pixiContainer> | ||||
| 	); | ||||
| } | ||||
							
								
								
									
										148
									
								
								src/pages/Route/route-preview/Station.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								src/pages/Route/route-preview/Station.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,148 @@ | ||||
| import { FederatedMouseEvent, Graphics } from "pixi.js"; | ||||
| import { | ||||
|   BACKGROUND_COLOR, | ||||
|   PATH_COLOR, | ||||
|   STATION_RADIUS, | ||||
|   STATION_OUTLINE_WIDTH, | ||||
|   UP_SCALE, | ||||
| } from "./Constants"; | ||||
| import { useTransform } from "./TransformContext"; | ||||
| import { useCallback, useEffect, useRef, useState } from "react"; | ||||
| import { StationData } from "./types"; | ||||
| import { useMapData } from "./MapDataContext"; | ||||
| import { coordinatesToLocal } from "./utils"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { languageStore } from "@shared"; | ||||
|  | ||||
| interface StationProps { | ||||
|   station: StationData; | ||||
|   ruLabel: string | null; | ||||
| } | ||||
|  | ||||
| export const Station = observer( | ||||
|   ({ station, ruLabel }: Readonly<StationProps>) => { | ||||
|     const draw = useCallback((g: Graphics) => { | ||||
|       g.clear(); | ||||
|       const coordinates = coordinatesToLocal( | ||||
|         station.latitude, | ||||
|         station.longitude | ||||
|       ); | ||||
|       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 ( | ||||
|       <pixiContainer> | ||||
|         <pixiGraphics draw={draw} /> | ||||
|         <StationLabel station={station} ruLabel={ruLabel} /> | ||||
|       </pixiContainer> | ||||
|     ); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| export const StationLabel = observer( | ||||
|   ({ station, ruLabel }: Readonly<StationProps>) => { | ||||
|     const { language } = languageStore; | ||||
|     const { rotation, scale } = useTransform(); | ||||
|     const { setStationOffset } = useMapData(); | ||||
|  | ||||
|     const [position, setPosition] = useState({ | ||||
|       x: station.offset_x, | ||||
|       y: station.offset_y, | ||||
|     }); | ||||
|     const [isDragging, setIsDragging] = useState(false); | ||||
|     const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); | ||||
|     const [startMousePosition, setStartMousePosition] = useState({ | ||||
|       x: 0, | ||||
|       y: 0, | ||||
|     }); | ||||
|  | ||||
|     if (!station) { | ||||
|       console.error("station is null"); | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     const handlePointerDown = (e: FederatedMouseEvent) => { | ||||
|       setIsDragging(true); | ||||
|       setStartPosition({ | ||||
|         x: position.x, | ||||
|         y: position.y, | ||||
|       }); | ||||
|       setStartMousePosition({ | ||||
|         x: e.globalX, | ||||
|         y: e.globalY, | ||||
|       }); | ||||
|  | ||||
|       e.stopPropagation(); | ||||
|     }; | ||||
|     const handlePointerMove = (e: FederatedMouseEvent) => { | ||||
|       if (!isDragging) return; | ||||
|       const dx = e.globalX - startMousePosition.x; | ||||
|       const dy = e.globalY - startMousePosition.y; | ||||
|       const newPosition = { | ||||
|         x: startPosition.x + dx, | ||||
|         y: startPosition.y + dy, | ||||
|       }; | ||||
|       setPosition(newPosition); | ||||
|       setStationOffset(station.id, newPosition.x, newPosition.y); | ||||
|       e.stopPropagation(); | ||||
|     }; | ||||
|  | ||||
|     const handlePointerUp = (e: FederatedMouseEvent) => { | ||||
|       setIsDragging(false); | ||||
|       e.stopPropagation(); | ||||
|     }; | ||||
|     const coordinates = coordinatesToLocal(station.latitude, station.longitude); | ||||
|  | ||||
|     return ( | ||||
|       <pixiContainer | ||||
|         eventMode="static" | ||||
|         interactive | ||||
|         onPointerDown={handlePointerDown} | ||||
|         onGlobalPointerMove={handlePointerMove} | ||||
|         onPointerUp={handlePointerUp} | ||||
|         onPointerUpOutside={handlePointerUp} | ||||
|         width={48} | ||||
|         height={48} | ||||
|         x={coordinates.x * UP_SCALE} | ||||
|         y={coordinates.y * UP_SCALE} | ||||
|         rotation={-rotation} | ||||
|       > | ||||
|         <pixiText | ||||
|           anchor={{ x: 1, y: 0.5 }} | ||||
|           text={station.name} | ||||
|           position={{ | ||||
|             x: position.x / scale + 24, | ||||
|             y: position.y / scale, | ||||
|           }} | ||||
|           style={{ | ||||
|             fontSize: 26, | ||||
|             fontWeight: "bold", | ||||
|             fill: "#ffffff", | ||||
|           }} | ||||
|         /> | ||||
|  | ||||
|         {ruLabel && ( | ||||
|           <pixiText | ||||
|             anchor={{ x: 1, y: -1 }} | ||||
|             text={ruLabel} | ||||
|             position={{ | ||||
|               x: position.x / scale + 24, | ||||
|               y: position.y / scale, | ||||
|             }} | ||||
|             style={{ | ||||
|               fontSize: 16, | ||||
|               fontWeight: "bold", | ||||
|               fill: "#CCCCCC", | ||||
|             }} | ||||
|           /> | ||||
|         )} | ||||
|       </pixiContainer> | ||||
|     ); | ||||
|   } | ||||
| ); | ||||
							
								
								
									
										204
									
								
								src/pages/Route/route-preview/TransformContext.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								src/pages/Route/route-preview/TransformContext.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,204 @@ | ||||
| import { | ||||
|   createContext, | ||||
|   ReactNode, | ||||
|   useContext, | ||||
|   useMemo, | ||||
|   useState, | ||||
|   useCallback, | ||||
| } from "react"; | ||||
| import { SCALE_FACTOR, UP_SCALE } from "./Constants"; | ||||
|  | ||||
| const TransformContext = createContext<{ | ||||
|   position: { x: number; y: number }; | ||||
|   scale: number; | ||||
|   rotation: number; | ||||
|   screenCenter?: { x: number; y: number }; | ||||
|  | ||||
|   setPosition: React.Dispatch<React.SetStateAction<{ x: number; y: number }>>; | ||||
|   setScale: React.Dispatch<React.SetStateAction<number>>; | ||||
|   setRotation: React.Dispatch<React.SetStateAction<number>>; | ||||
|   screenToLocal: (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; | ||||
|   setTransform: ( | ||||
|     latitude: number, | ||||
|     longitude: number, | ||||
|     rotationDegrees?: number, | ||||
|     scale?: number | ||||
|   ) => void; | ||||
|   setScreenCenter: React.Dispatch< | ||||
|     React.SetStateAction<{ x: number; y: number } | undefined> | ||||
|   >; | ||||
| }>({ | ||||
|   position: { x: 0, y: 0 }, | ||||
|   scale: 1, | ||||
|   rotation: 0, | ||||
|   screenCenter: undefined, | ||||
|   setPosition: () => {}, | ||||
|   setScale: () => {}, | ||||
|   setRotation: () => {}, | ||||
|   screenToLocal: () => ({ x: 0, y: 0 }), | ||||
|   localToScreen: () => ({ x: 0, y: 0 }), | ||||
|   rotateToAngle: () => {}, | ||||
|   setTransform: () => {}, | ||||
|   setScreenCenter: () => {}, | ||||
| }); | ||||
|  | ||||
| // Provider component | ||||
| export const TransformProvider = ({ children }: { children: ReactNode }) => { | ||||
|   const [position, setPosition] = useState({ x: 0, y: 0 }); | ||||
|   const [scale, setScale] = useState(1); | ||||
|   const [rotation, setRotation] = useState(0); | ||||
|   const [screenCenter, setScreenCenter] = useState<{ x: number; y: number }>(); | ||||
|  | ||||
|   const screenToLocal = useCallback( | ||||
|     (screenX: number, screenY: number) => { | ||||
|       // Translate point relative to current pan position | ||||
|       const translatedX = (screenX - position.x) / scale; | ||||
|       const translatedY = (screenY - position.y) / scale; | ||||
|  | ||||
|       // Rotate point around center | ||||
|       const cosRotation = Math.cos(-rotation); // Negative rotation to reverse transform | ||||
|       const sinRotation = Math.sin(-rotation); | ||||
|       const rotatedX = translatedX * cosRotation - translatedY * sinRotation; | ||||
|       const rotatedY = translatedX * sinRotation + translatedY * cosRotation; | ||||
|  | ||||
|       return { | ||||
|         x: rotatedX / UP_SCALE, | ||||
|         y: rotatedY / UP_SCALE, | ||||
|       }; | ||||
|     }, | ||||
|     [position.x, position.y, scale, rotation] | ||||
|   ); | ||||
|  | ||||
|   // Inverse of screenToLocal | ||||
|   const localToScreen = useCallback( | ||||
|     (localX: number, localY: number) => { | ||||
|       const upscaledX = localX * UP_SCALE; | ||||
|       const upscaledY = localY * UP_SCALE; | ||||
|  | ||||
|       const cosRotation = Math.cos(rotation); | ||||
|       const sinRotation = Math.sin(rotation); | ||||
|       const rotatedX = upscaledX * cosRotation - upscaledY * sinRotation; | ||||
|       const rotatedY = upscaledX * sinRotation + upscaledY * cosRotation; | ||||
|  | ||||
|       const translatedX = rotatedX * scale + position.x; | ||||
|       const translatedY = rotatedY * scale + position.y; | ||||
|  | ||||
|       return { | ||||
|         x: translatedX, | ||||
|         y: translatedY, | ||||
|       }; | ||||
|     }, | ||||
|     [position.x, position.y, scale, rotation] | ||||
|   ); | ||||
|  | ||||
|   const rotateToAngle = useCallback( | ||||
|     (to: number, fromPosition?: { x: number; y: number }) => { | ||||
|       const rotationDiff = to - rotation; | ||||
|  | ||||
|       const center = screenCenter ?? { x: 0, y: 0 }; | ||||
|       const cosDelta = Math.cos(rotationDiff); | ||||
|       const sinDelta = Math.sin(rotationDiff); | ||||
|  | ||||
|       const currentFromPosition = 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, | ||||
|       }; | ||||
|  | ||||
|       // Update both rotation and position in a single batch to avoid stale closure | ||||
|       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 | ||||
|       const dx = newPosition.x; | ||||
|       const dy = newPosition.y; | ||||
|       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 | ||||
| export const useTransform = () => { | ||||
|   const context = useContext(TransformContext); | ||||
|   if (!context) { | ||||
|     throw new Error("useTransform must be used within a TransformProvider"); | ||||
|   } | ||||
|   return context; | ||||
| }; | ||||
							
								
								
									
										34
									
								
								src/pages/Route/route-preview/TravelPath.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/pages/Route/route-preview/TravelPath.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| import { Graphics } from "pixi.js"; | ||||
| import { useCallback } from "react"; | ||||
| import { PATH_COLOR, PATH_WIDTH } from "./Constants"; | ||||
| import { coordinatesToLocal } from "./utils"; | ||||
|  | ||||
| interface TravelPathProps { | ||||
|   points: { x: number; y: number }[]; | ||||
| } | ||||
|  | ||||
| export function TravelPath({ points }: Readonly<TravelPathProps>) { | ||||
|   const draw = useCallback( | ||||
|     (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] | ||||
|   ); | ||||
|  | ||||
|   if (points.length === 0) { | ||||
|     console.error("points is empty"); | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return <pixiGraphics draw={draw} />; | ||||
| } | ||||
							
								
								
									
										43
									
								
								src/pages/Route/route-preview/Widgets.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/pages/Route/route-preview/Widgets.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| import { Stack, Typography } from "@mui/material"; | ||||
|  | ||||
| export function Widgets() { | ||||
|   return ( | ||||
|     <Stack | ||||
|       direction="column" | ||||
|       spacing={2} | ||||
|       position="absolute" | ||||
|       top={32} | ||||
|       left={32} | ||||
|       sx={{ pointerEvents: "none" }} | ||||
|     > | ||||
|       <Stack | ||||
|         bgcolor="primary.main" | ||||
|         width={361} | ||||
|         height={96} | ||||
|         p={2} | ||||
|         m={2} | ||||
|         borderRadius={2} | ||||
|         alignItems="center" | ||||
|         justifyContent="center" | ||||
|       > | ||||
|         <Typography variant="h6" sx={{ color: "#fff" }}> | ||||
|           Станция | ||||
|         </Typography> | ||||
|       </Stack> | ||||
|       <Stack | ||||
|         bgcolor="primary.main" | ||||
|         width={223} | ||||
|         height={262} | ||||
|         p={2} | ||||
|         m={2} | ||||
|         borderRadius={2} | ||||
|         alignItems="center" | ||||
|         justifyContent="center" | ||||
|       > | ||||
|         <Typography variant="h6" sx={{ color: "#fff" }}> | ||||
|           Погода | ||||
|         </Typography> | ||||
|       </Stack> | ||||
|     </Stack> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										176
									
								
								src/pages/Route/route-preview/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								src/pages/Route/route-preview/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,176 @@ | ||||
| import { useRef, useEffect, useState } from "react"; | ||||
|  | ||||
| import { Application, ApplicationRef, extend } from "@pixi/react"; | ||||
| import { | ||||
|   Container, | ||||
|   Graphics, | ||||
|   Sprite, | ||||
|   Texture, | ||||
|   TilingSprite, | ||||
|   Text, | ||||
| } from "pixi.js"; | ||||
| import { Stack } from "@mui/material"; | ||||
| import { MapDataProvider, useMapData } from "./MapDataContext"; | ||||
| import { TransformProvider, useTransform } from "./TransformContext"; | ||||
| import { InfiniteCanvas } from "./InfiniteCanvas"; | ||||
| import { Sight } from "./Sight"; | ||||
| import { UP_SCALE } from "./Constants"; | ||||
| import { Station } from "./Station"; | ||||
| import { TravelPath } from "./TravelPath"; | ||||
| import { LeftSidebar } from "./LeftSidebar"; | ||||
| import { RightSidebar } from "./RightSidebar"; | ||||
| import { Widgets } from "./Widgets"; | ||||
| import { coordinatesToLocal } from "./utils"; | ||||
| import { LanguageSwitcher } from "@widgets"; | ||||
| import { languageStore } from "@shared"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
|  | ||||
| extend({ | ||||
|   Container, | ||||
|   Graphics, | ||||
|   Sprite, | ||||
|   Texture, | ||||
|   TilingSprite, | ||||
|   Text, | ||||
| }); | ||||
|  | ||||
| export const RoutePreview = () => { | ||||
|   return ( | ||||
|     <MapDataProvider> | ||||
|       <TransformProvider> | ||||
|         <Stack direction="row" height="90vh" width="90vw" overflow="hidden"> | ||||
|           <div | ||||
|             style={{ | ||||
|               position: "absolute", | ||||
|               top: 0, | ||||
|               left: "50%", | ||||
|               transform: "translateX(-50%)", | ||||
|               zIndex: 1000, | ||||
|             }} | ||||
|           > | ||||
|             <LanguageSwitcher /> | ||||
|           </div> | ||||
|           <LeftSidebar /> | ||||
|           <Stack direction="row" flex={1} position="relative" height="100%"> | ||||
|             <Widgets /> | ||||
|             <RouteMap /> | ||||
|             <RightSidebar /> | ||||
|           </Stack> | ||||
|         </Stack> | ||||
|       </TransformProvider> | ||||
|     </MapDataProvider> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export const RouteMap = observer(() => { | ||||
|   const { language } = languageStore; | ||||
|   const { setPosition, screenToLocal, setTransform, screenCenter } = | ||||
|     useTransform(); | ||||
|   const { routeData, stationData, sightData, originalRouteData } = useMapData(); | ||||
|   console.log(stationData); | ||||
|   const [points, setPoints] = useState<{ x: number; y: number }[]>([]); | ||||
|   const [isSetup, setIsSetup] = useState(false); | ||||
|  | ||||
|   const parentRef = useRef<HTMLDivElement>(null); | ||||
|  | ||||
|   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(() => { | ||||
|     if (isSetup || !screenCenter) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if ( | ||||
|       originalRouteData?.center_latitude === | ||||
|         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, | ||||
|   ]); | ||||
|  | ||||
|   if (!routeData || !stationData || !sightData) { | ||||
|     console.error("routeData, stationData or sightData is null"); | ||||
|     return <div>Loading...</div>; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div style={{ width: "100%", height: "100%" }} ref={parentRef}> | ||||
|       <Application resizeTo={parentRef} background="#fff"> | ||||
|         <InfiniteCanvas> | ||||
|           <TravelPath points={points} /> | ||||
|           {stationData[language].map((obj, index) => ( | ||||
|             <Station | ||||
|               station={obj} | ||||
|               key={obj.id} | ||||
|               ruLabel={ | ||||
|                 language === "ru" | ||||
|                   ? stationData.en[index].name | ||||
|                   : stationData.ru[index].name | ||||
|               } | ||||
|             /> | ||||
|           ))} | ||||
|  | ||||
|           <pixiGraphics | ||||
|             draw={(g) => { | ||||
|               g.clear(); | ||||
|               const localCenter = screenToLocal(0, 0); | ||||
|               g.circle(localCenter.x, localCenter.y, 10); | ||||
|               g.fill("#fff"); | ||||
|             }} | ||||
|           /> | ||||
|         </InfiniteCanvas> | ||||
|       </Application> | ||||
|     </div> | ||||
|   ); | ||||
| }); | ||||
							
								
								
									
										69
									
								
								src/pages/Route/route-preview/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								src/pages/Route/route-preview/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| export interface RouteData { | ||||
| 	carrier: string; | ||||
| 	carrier_id: number; | ||||
| 	center_latitude: number; | ||||
| 	center_longitude: number; | ||||
| 	governor_appeal: number; | ||||
| 	id: number; | ||||
| 	path: [number, number][]; | ||||
| 	rotate: number; | ||||
| 	route_direction: boolean; | ||||
| 	route_number: string; | ||||
| 	route_sys_number: string; | ||||
| 	scale_max: number; | ||||
| 	scale_min: number; | ||||
| } | ||||
|  | ||||
| export interface StationTransferData { | ||||
| 	bus: string; | ||||
| 	metro_blue: string; | ||||
| 	metro_green: string; | ||||
| 	metro_orange: string; | ||||
| 	metro_purple: string; | ||||
| 	metro_red: string; | ||||
| 	train: string; | ||||
| 	tram: string; | ||||
| 	trolleybus: string; | ||||
| } | ||||
|  | ||||
| export interface StationData { | ||||
| 	address: string; | ||||
| 	city_id?: number; | ||||
| 	description: string; | ||||
| 	id: number; | ||||
| 	latitude: number; | ||||
| 	longitude: number; | ||||
| 	name: string; | ||||
| 	offset_x: number; | ||||
| 	offset_y: number; | ||||
| 	system_name: string; | ||||
| 	transfers: StationTransferData; | ||||
| } | ||||
|  | ||||
| export interface StationPatchData { | ||||
| 	station_id: number; | ||||
| 	offset_x: number; | ||||
| 	offset_y: number; | ||||
| 	transfers: StationTransferData; | ||||
| } | ||||
|  | ||||
| export interface SightPatchData { | ||||
| 	sight_id: number; | ||||
| 	latitude: number; | ||||
| 	longitude: number; | ||||
| } | ||||
|  | ||||
| export interface SightData { | ||||
| 	address: string; | ||||
| 	city: string; | ||||
| 	city_id: number; | ||||
| 	id: number; | ||||
| 	latitude: number; | ||||
| 	left_article: number; | ||||
| 	longitude: number; | ||||
| 	name: string; | ||||
| 	preview_media: number; | ||||
| 	thumbnail: string; // uuid | ||||
| 	watermark_lu: string; // uuid | ||||
| 	watermark_rd: string; // uuid | ||||
| } | ||||
							
								
								
									
										14
									
								
								src/pages/Route/route-preview/utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/pages/Route/route-preview/utils.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| // approximation | ||||
| export function coordinatesToLocal(latitude: number, longitude: number) { | ||||
|     return { | ||||
|         x: longitude, | ||||
|         y: -latitude*2, | ||||
|     } | ||||
| } | ||||
|  | ||||
| export function localToCoordinates(x: number, y: number) { | ||||
| 	return { | ||||
|         longitude: x, | ||||
|         latitude: -y/2, | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user