fix: Update map with tables fixes
This commit is contained in:
		| @@ -2,8 +2,8 @@ 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 SIGHT_SIZE = 40; | ||||
| export const SCALE_FACTOR = 50; | ||||
|  | ||||
| export const BACKGROUND_COLOR = 0x111111; | ||||
| export const PATH_COLOR = 0xff4d4d; | ||||
| export const PATH_COLOR = 0xff4d4d; | ||||
|   | ||||
| @@ -37,7 +37,7 @@ export function InfiniteCanvas({ | ||||
|     setScreenCenter, | ||||
|     screenCenter, | ||||
|   } = useTransform(); | ||||
|   const { routeData, originalRouteData } = useMapData(); | ||||
|   const { routeData, originalRouteData, setSelectedSight } = useMapData(); | ||||
|  | ||||
|   const applicationRef = useApplication(); | ||||
|  | ||||
| @@ -45,6 +45,7 @@ export function InfiniteCanvas({ | ||||
|   const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 }); | ||||
|   const [startRotation, setStartRotation] = useState(0); | ||||
|   const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); | ||||
|   const [isPointerDown, setIsPointerDown] = useState(false); | ||||
|  | ||||
|   // Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута | ||||
|   const [isUserInteracting, setIsUserInteracting] = useState(false); | ||||
| @@ -65,7 +66,8 @@ export function InfiniteCanvas({ | ||||
|   }, [applicationRef?.app.canvas, setScreenCenter]); | ||||
|  | ||||
|   const handlePointerDown = (e: FederatedMouseEvent) => { | ||||
|     setIsDragging(true); | ||||
|     setIsPointerDown(true); | ||||
|     setIsDragging(false); | ||||
|     setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя | ||||
|     setStartPosition({ | ||||
|       x: position.x, | ||||
| @@ -93,7 +95,18 @@ export function InfiniteCanvas({ | ||||
|   }, [originalRouteData?.rotate, isUserInteracting, setRotation]); | ||||
|  | ||||
|   const handlePointerMove = (e: FederatedMouseEvent) => { | ||||
|     if (!isDragging) return; | ||||
|     if (!isPointerDown) return; | ||||
|  | ||||
|     // Проверяем, началось ли перетаскивание | ||||
|     if (!isDragging) { | ||||
|       const dx = e.globalX - startMousePosition.x; | ||||
|       const dy = e.globalY - startMousePosition.y; | ||||
|       if (Math.abs(dx) > 5 || Math.abs(dy) > 5) { | ||||
|         setIsDragging(true); | ||||
|       } else { | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (e.shiftKey) { | ||||
|       const center = screenCenter ?? { x: 0, y: 0 }; | ||||
| @@ -136,6 +149,12 @@ export function InfiniteCanvas({ | ||||
|   }; | ||||
|  | ||||
|   const handlePointerUp = (e: FederatedMouseEvent) => { | ||||
|     // Если не было перетаскивания, то это простой клик - закрываем виджет | ||||
|     if (!isDragging) { | ||||
|       setSelectedSight(undefined); | ||||
|     } | ||||
|  | ||||
|     setIsPointerDown(false); | ||||
|     setIsDragging(false); | ||||
|     // Сбрасываем флаг взаимодействия через небольшую задержку | ||||
|     // чтобы избежать немедленного срабатывания useEffect | ||||
| @@ -185,7 +204,6 @@ export function InfiniteCanvas({ | ||||
|  | ||||
|   useEffect(() => { | ||||
|     applicationRef?.app.render(); | ||||
|     console.log(position, scale, rotation); | ||||
|   }, [position, scale, rotation]); | ||||
|  | ||||
|   return ( | ||||
|   | ||||
| @@ -1,10 +1,30 @@ | ||||
| import { Stack, Typography, Button } from "@mui/material"; | ||||
|  | ||||
| import { useNavigate, useNavigationType } from "react-router"; | ||||
| import { MediaViewer } from "@widgets"; | ||||
| import { useMapData } from "./MapDataContext"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { authInstance } from "@shared"; | ||||
|  | ||||
| export function LeftSidebar() { | ||||
| export const LeftSidebar = observer(() => { | ||||
|   const navigate = useNavigate(); | ||||
|   const navigationType = useNavigationType(); // PUSH, POP, REPLACE | ||||
|   const { routeData } = useMapData(); | ||||
|   const [carrierThumbnail, setCarrierThumbnail] = useState<string | null>(null); | ||||
|   const [carrierLogo, setCarrierLogo] = useState<string | null>(null); | ||||
|   useEffect(() => { | ||||
|     async function fetchCarrierThumbnail() { | ||||
|       if (routeData?.carrier_id) { | ||||
|         const { city_id, logo } = ( | ||||
|           await authInstance.get(`/carrier/${routeData.carrier_id}`) | ||||
|         ).data; | ||||
|         const { arms } = (await authInstance.get(`/city/${city_id}`)).data; | ||||
|         setCarrierThumbnail(arms); | ||||
|         setCarrierLogo(logo); | ||||
|       } | ||||
|     } | ||||
|     fetchCarrierThumbnail(); | ||||
|   }, [routeData?.carrier_id]); | ||||
|  | ||||
|   const handleBack = () => { | ||||
|     if (navigationType === "PUSH") { | ||||
| @@ -27,6 +47,7 @@ export function LeftSidebar() { | ||||
|           color: "#fff", | ||||
|           backgroundColor: "#222", | ||||
|           borderRadius: 10, | ||||
|           height: 40, | ||||
|           width: "100%", | ||||
|           border: "none", | ||||
|           cursor: "pointer", | ||||
| @@ -41,10 +62,30 @@ export function LeftSidebar() { | ||||
|         justifyContent="center" | ||||
|         my={10} | ||||
|       > | ||||
|         <img src={"/Emblem.svg"} alt="logo" width={100} height={100} /> | ||||
|         <Typography sx={{ mb: 2, color: "#fff" }} textAlign="center"> | ||||
|           При поддержке Правительства Санкт-Петербурга | ||||
|         </Typography> | ||||
|         <div | ||||
|           style={{ | ||||
|             maxWidth: 200, | ||||
|             display: "flex", | ||||
|             flexDirection: "column", | ||||
|             alignItems: "center", | ||||
|             gap: 10, | ||||
|           }} | ||||
|         > | ||||
|           {carrierThumbnail && ( | ||||
|             <MediaViewer | ||||
|               media={{ | ||||
|                 id: carrierThumbnail, | ||||
|                 media_type: 1, // Тип "Фото" для логотипа | ||||
|                 filename: "route_thumbnail", | ||||
|               }} | ||||
|               fullWidth | ||||
|               fullHeight | ||||
|             /> | ||||
|           )} | ||||
|           <Typography sx={{ mb: 2, color: "#fff" }} textAlign="center"> | ||||
|             При поддержке Правительства | ||||
|           </Typography>{" "} | ||||
|         </div> | ||||
|       </Stack> | ||||
|  | ||||
|       <Stack | ||||
| @@ -65,15 +106,20 @@ export function LeftSidebar() { | ||||
|       <Stack | ||||
|         direction="column" | ||||
|         alignItems="center" | ||||
|         maxHeight={150} | ||||
|         justifyContent="center" | ||||
|         my={10} | ||||
|       > | ||||
|         <img | ||||
|           src={"/GET.png"} | ||||
|           alt="logo" | ||||
|           width="80%" | ||||
|           style={{ margin: "0 auto" }} | ||||
|         /> | ||||
|         {carrierLogo && ( | ||||
|           <MediaViewer | ||||
|             media={{ | ||||
|               id: carrierLogo, | ||||
|               media_type: 1, // Тип "Фото" для логотипа | ||||
|               filename: "route_thumbnail_logo", | ||||
|             }} | ||||
|             fullHeight | ||||
|           /> | ||||
|         )} | ||||
|       </Stack> | ||||
|  | ||||
|       <Typography | ||||
| @@ -86,4 +132,4 @@ export function LeftSidebar() { | ||||
|       </Typography> | ||||
|     </Stack> | ||||
|   ); | ||||
| } | ||||
| }); | ||||
|   | ||||
| @@ -29,10 +29,13 @@ const MapDataContext = createContext<{ | ||||
|   isRouteLoading: boolean; | ||||
|   isStationLoading: boolean; | ||||
|   isSightLoading: boolean; | ||||
|   selectedSight?: SightData; | ||||
|   setSelectedSight: (sight?: SightData) => void; | ||||
|   setScaleRange: (min: number, max: number) => void; | ||||
|   setMapRotation: (rotation: number) => void; | ||||
|   setMapCenter: (x: number, y: number) => void; | ||||
|   setStationOffset: (stationId: number, x: number, y: number) => void; | ||||
|   setStationAlign: (stationId: number, align: number) => void; | ||||
|   setSightCoordinates: ( | ||||
|     sightId: number, | ||||
|     latitude: number, | ||||
| @@ -50,10 +53,13 @@ const MapDataContext = createContext<{ | ||||
|   isRouteLoading: true, | ||||
|   isStationLoading: true, | ||||
|   isSightLoading: true, | ||||
|   selectedSight: undefined, | ||||
|   setSelectedSight: () => {}, | ||||
|   setScaleRange: () => {}, | ||||
|   setMapRotation: () => {}, | ||||
|   setMapCenter: () => {}, | ||||
|   setStationOffset: () => {}, | ||||
|   setStationAlign: () => {}, | ||||
|   setSightCoordinates: () => {}, | ||||
|   saveChanges: () => {}, | ||||
| }); | ||||
| @@ -87,6 +93,7 @@ export const MapDataProvider = observer( | ||||
|     const [isRouteLoading, setIsRouteLoading] = useState(true); | ||||
|     const [isStationLoading, setIsStationLoading] = useState(true); | ||||
|     const [isSightLoading, setIsSightLoading] = useState(true); | ||||
|     const [selectedSight, setSelectedSight] = useState<SightData>(); | ||||
|  | ||||
|     useEffect(() => { | ||||
|       const fetchData = async () => { | ||||
| @@ -106,17 +113,18 @@ export const MapDataProvider = observer( | ||||
|             languageInstance("ru").get(`/route/${routeId}/station`), | ||||
|             languageInstance("en").get(`/route/${routeId}/station`), | ||||
|             languageInstance("zh").get(`/route/${routeId}/station`), | ||||
|             authInstance.get(`/route/${routeId}/sight`), | ||||
|             languageInstance("ru").get(`/route/${routeId}/sight`), | ||||
|           ]); | ||||
|  | ||||
|           setOriginalRouteData(routeResponse.data as RouteData); | ||||
|           const routeData = routeResponse.data as RouteData; | ||||
|           setOriginalRouteData(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[]); | ||||
|           setOriginalSightData(sightResponse.data as SightData[]); | ||||
|  | ||||
|           setIsRouteLoading(false); | ||||
|           setIsStationLoading(false); | ||||
| @@ -176,43 +184,136 @@ export const MapDataProvider = observer( | ||||
|     } | ||||
|  | ||||
|     async function saveSightChanges() { | ||||
|       console.log("sightChanges", sightChanges); | ||||
|       for (const sight of sightChanges) { | ||||
|         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; | ||||
|       const currentStation = stationData.ru?.find( | ||||
|         (station) => station.id === stationId | ||||
|       ); | ||||
|       if ( | ||||
|         currentStation && | ||||
|         Math.abs(currentStation.offset_x - x) < 0.01 && | ||||
|         Math.abs(currentStation.offset_y - y) < 0.01 | ||||
|       ) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|           return prev.map((station) => { | ||||
|             if (station.station_id === stationId) { | ||||
|               return found; | ||||
|       setStationChanges((prev) => { | ||||
|         const existingIndex = prev.findIndex( | ||||
|           (station) => station.station_id === stationId | ||||
|         ); | ||||
|  | ||||
|         if (existingIndex !== -1) { | ||||
|           const newChanges = [...prev]; | ||||
|           newChanges[existingIndex] = { | ||||
|             ...newChanges[existingIndex], | ||||
|             offset_x: x, | ||||
|             offset_y: y, | ||||
|           }; | ||||
|           return newChanges; | ||||
|         } else { | ||||
|           const originalStation = originalStationData?.find( | ||||
|             (s) => s.id === stationId | ||||
|           ); | ||||
|           return [ | ||||
|             ...prev, | ||||
|             { | ||||
|               station_id: stationId, | ||||
|               offset_x: x, | ||||
|               offset_y: y, | ||||
|               align: originalStation?.align ?? 1, | ||||
|               transfers: originalStation?.transfers ?? { | ||||
|                 bus: "", | ||||
|                 metro_blue: "", | ||||
|                 metro_green: "", | ||||
|                 metro_orange: "", | ||||
|                 metro_purple: "", | ||||
|                 metro_red: "", | ||||
|                 train: "", | ||||
|                 tram: "", | ||||
|                 trolleybus: "", | ||||
|               }, | ||||
|             }, | ||||
|           ]; | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       setStationData((prev) => { | ||||
|         const updated = { ...prev }; | ||||
|         Object.keys(updated).forEach((lang) => { | ||||
|           updated[lang] = updated[lang].map((station) => { | ||||
|             if (station.id === stationId) { | ||||
|               return { ...station, offset_x: x, offset_y: y }; | ||||
|             } | ||||
|             return station; | ||||
|           }); | ||||
|         }); | ||||
|         return updated; | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     function setStationAlign(stationId: number, align: number) { | ||||
|       const currentStation = stationData.ru?.find( | ||||
|         (station) => station.id === stationId | ||||
|       ); | ||||
|       if (currentStation && currentStation.align === align) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       setStationChanges((prev) => { | ||||
|         const existingIndex = prev.findIndex( | ||||
|           (station) => station.station_id === stationId | ||||
|         ); | ||||
|  | ||||
|         if (existingIndex !== -1) { | ||||
|           const newChanges = [...prev]; | ||||
|           newChanges[existingIndex] = { | ||||
|             ...newChanges[existingIndex], | ||||
|             align: align, | ||||
|           }; | ||||
|           return newChanges; | ||||
|         } else { | ||||
|           const foundStation = stationData.ru?.find( | ||||
|             (station) => station.id === stationId | ||||
|           const originalStation = originalStationData?.find( | ||||
|             (s) => s.id === stationId | ||||
|           ); | ||||
|           if (foundStation) { | ||||
|             return [ | ||||
|               ...prev, | ||||
|               { | ||||
|                 station_id: stationId, | ||||
|                 offset_x: x, | ||||
|                 offset_y: y, | ||||
|                 transfers: foundStation.transfers, | ||||
|           return [ | ||||
|             ...prev, | ||||
|             { | ||||
|               station_id: stationId, | ||||
|               align: align, | ||||
|               offset_x: originalStation?.offset_x ?? 0, | ||||
|               offset_y: originalStation?.offset_y ?? 0, | ||||
|               transfers: originalStation?.transfers ?? { | ||||
|                 bus: "", | ||||
|                 metro_blue: "", | ||||
|                 metro_green: "", | ||||
|                 metro_orange: "", | ||||
|                 metro_purple: "", | ||||
|                 metro_red: "", | ||||
|                 train: "", | ||||
|                 tram: "", | ||||
|                 trolleybus: "", | ||||
|               }, | ||||
|             ]; | ||||
|           } | ||||
|           return prev; | ||||
|             }, | ||||
|           ]; | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       setStationData((prev) => { | ||||
|         const updated = { ...prev }; | ||||
|         Object.keys(updated).forEach((lang) => { | ||||
|           updated[lang] = updated[lang].map((station) => { | ||||
|             if (station.id === stationId) { | ||||
|               return { ...station, align: align }; | ||||
|             } | ||||
|             return station; | ||||
|           }); | ||||
|         }); | ||||
|         return updated; | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     function setSightCoordinates( | ||||
| @@ -221,14 +322,18 @@ export const MapDataProvider = observer( | ||||
|       longitude: number | ||||
|     ) { | ||||
|       setSightChanges((prev) => { | ||||
|         let found = prev.find((sight) => sight.sight_id === sightId); | ||||
|         if (found) { | ||||
|           found.latitude = latitude; | ||||
|           found.longitude = longitude; | ||||
|         const existingIndex = prev.findIndex( | ||||
|           (sight) => sight.sight_id === sightId | ||||
|         ); | ||||
|  | ||||
|           return prev.map((sight) => { | ||||
|             if (sight.sight_id === sightId) { | ||||
|               return found; | ||||
|         if (existingIndex !== -1) { | ||||
|           return prev.map((sight, index) => { | ||||
|             if (index === existingIndex) { | ||||
|               return { | ||||
|                 ...sight, | ||||
|                 latitude, | ||||
|                 longitude, | ||||
|               }; | ||||
|             } | ||||
|             return sight; | ||||
|           }); | ||||
| @@ -249,9 +354,7 @@ export const MapDataProvider = observer( | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     useEffect(() => { | ||||
|       console.log("sightChanges", sightChanges); | ||||
|     }, [sightChanges]); | ||||
|     useEffect(() => {}, [sightChanges]); | ||||
|  | ||||
|     const value = useMemo( | ||||
|       () => ({ | ||||
| @@ -264,11 +367,14 @@ export const MapDataProvider = observer( | ||||
|         isRouteLoading, | ||||
|         isStationLoading, | ||||
|         isSightLoading, | ||||
|         selectedSight, | ||||
|         setSelectedSight, | ||||
|         setScaleRange, | ||||
|         setMapRotation, | ||||
|         setMapCenter, | ||||
|         saveChanges, | ||||
|         setStationOffset, | ||||
|         setStationAlign, | ||||
|         setSightCoordinates, | ||||
|       }), | ||||
|       [ | ||||
| @@ -281,6 +387,7 @@ export const MapDataProvider = observer( | ||||
|         isRouteLoading, | ||||
|         isStationLoading, | ||||
|         isSightLoading, | ||||
|         selectedSight, | ||||
|       ] | ||||
|     ); | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,9 @@ | ||||
| import { Button, Stack, TextField, Typography } from "@mui/material"; | ||||
| import { Button, Stack, TextField, Typography, Slider } from "@mui/material"; | ||||
| import { useMapData } from "./MapDataContext"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { useTransform } from "./TransformContext"; | ||||
| import { coordinatesToLocal } from "./utils"; | ||||
| import { coordinatesToLocal, localToCoordinates } from "./utils"; | ||||
| import { SCALE_FACTOR } from "./Constants"; | ||||
|  | ||||
| export function RightSidebar() { | ||||
|   const { | ||||
| @@ -15,24 +16,36 @@ export function RightSidebar() { | ||||
|   } = useMapData(); | ||||
|   const { | ||||
|     rotation, | ||||
|     // position, | ||||
|     // screenToLocal, | ||||
|     // screenCenter, | ||||
|     position, | ||||
|     screenToLocal, | ||||
|     screenCenter, | ||||
|     rotateToAngle, | ||||
|     setTransform, | ||||
|     scale, | ||||
|     setScaleAtCenter, | ||||
|   } = useTransform(); | ||||
|  | ||||
|   const [minScale, setMinScale] = useState<number>(1); | ||||
|   const [maxScale, setMaxScale] = useState<number>(10); | ||||
|   const [maxScale, setMaxScale] = useState<number>(5); | ||||
|   const [localCenter, setLocalCenter] = useState<{ x: number; y: number }>({ | ||||
|     x: 0, | ||||
|     y: 0, | ||||
|   }); | ||||
|   const [rotationDegrees, setRotationDegrees] = useState<number>(0); | ||||
|   const [isUserEditing, setIsUserEditing] = useState<boolean>(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (originalRouteData) { | ||||
|       setMinScale(originalRouteData.scale_min ?? 1); | ||||
|       setMaxScale(originalRouteData.scale_max ?? 10); | ||||
|       // Проверяем и сбрасываем минимальный масштаб если нужно | ||||
|       const originalMinScale = originalRouteData.scale_min ?? 1; | ||||
|       const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale; | ||||
|  | ||||
|       // Проверяем и сбрасываем максимальный масштаб если нужно | ||||
|       const originalMaxScale = originalRouteData.scale_max ?? 5; | ||||
|       const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale; | ||||
|  | ||||
|       setMinScale(resetMinScale); | ||||
|       setMaxScale(resetMaxScale); | ||||
|       setRotationDegrees(originalRouteData.rotate ?? 0); | ||||
|       setLocalCenter({ | ||||
|         x: originalRouteData.center_latitude ?? 0, | ||||
| @@ -52,16 +65,26 @@ export function RightSidebar() { | ||||
|       ((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(() => { | ||||
|     if (!isUserEditing) { | ||||
|       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, | ||||
|     screenCenter, | ||||
|     screenToLocal, | ||||
|     localToCoordinates, | ||||
|     setLocalCenter, | ||||
|     isUserEditing, | ||||
|   ]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setMapCenter(localCenter.x, localCenter.y); | ||||
| @@ -104,7 +127,30 @@ export function RightSidebar() { | ||||
|           label="Минимальный масштаб" | ||||
|           variant="filled" | ||||
|           value={minScale} | ||||
|           onChange={(e) => setMinScale(Number(e.target.value))} | ||||
|           onChange={(e) => { | ||||
|             let newMinScale = Number(e.target.value); | ||||
|  | ||||
|             // Сбрасываем к 1 если меньше | ||||
|             if (newMinScale < 1) { | ||||
|               newMinScale = 1; | ||||
|             } | ||||
|  | ||||
|             setMinScale(newMinScale); | ||||
|  | ||||
|             if (maxScale - newMinScale < 2) { | ||||
|               let newMaxScale = newMinScale + 2; | ||||
|               // Сбрасываем максимальный к 3 если меньше минимального | ||||
|               if (newMaxScale < 3) { | ||||
|                 newMaxScale = 3; | ||||
|                 setMinScale(1); // Сбрасываем минимальный к 1 | ||||
|               } | ||||
|               setMaxScale(newMaxScale); | ||||
|             } | ||||
|  | ||||
|             if (newMinScale > scale * SCALE_FACTOR) { | ||||
|               setScaleAtCenter(newMinScale / SCALE_FACTOR); | ||||
|             } | ||||
|           }} | ||||
|           style={{ backgroundColor: "#222", borderRadius: 4 }} | ||||
|           sx={{ | ||||
|             "& .MuiInputLabel-root": { | ||||
| @@ -116,7 +162,8 @@ export function RightSidebar() { | ||||
|           }} | ||||
|           slotProps={{ | ||||
|             input: { | ||||
|               min: 0.1, | ||||
|               min: 1, | ||||
|               max: 10, | ||||
|             }, | ||||
|           }} | ||||
|         /> | ||||
| @@ -125,7 +172,30 @@ export function RightSidebar() { | ||||
|           label="Максимальный масштаб" | ||||
|           variant="filled" | ||||
|           value={maxScale} | ||||
|           onChange={(e) => setMaxScale(Number(e.target.value))} | ||||
|           onChange={(e) => { | ||||
|             let newMaxScale = Number(e.target.value); | ||||
|  | ||||
|             // Сбрасываем к 3 если меньше минимального | ||||
|             if (newMaxScale < 3) { | ||||
|               newMaxScale = 3; | ||||
|             } | ||||
|  | ||||
|             setMaxScale(newMaxScale); | ||||
|  | ||||
|             if (newMaxScale - minScale < 2) { | ||||
|               let newMinScale = newMaxScale - 2; | ||||
|               // Сбрасываем минимальный к 1 если меньше | ||||
|               if (newMinScale < 1) { | ||||
|                 newMinScale = 1; | ||||
|                 setMaxScale(3); // Сбрасываем максимальный к минимальному значению | ||||
|               } | ||||
|               setMinScale(newMinScale); | ||||
|             } | ||||
|  | ||||
|             if (newMaxScale < scale * SCALE_FACTOR) { | ||||
|               setScaleAtCenter(newMaxScale / SCALE_FACTOR); | ||||
|             } | ||||
|           }} | ||||
|           style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }} | ||||
|           sx={{ | ||||
|             "& .MuiInputLabel-root": { | ||||
| @@ -137,12 +207,71 @@ export function RightSidebar() { | ||||
|           }} | ||||
|           slotProps={{ | ||||
|             input: { | ||||
|               min: 0.1, | ||||
|               min: 3, | ||||
|               max: 10, | ||||
|             }, | ||||
|           }} | ||||
|         /> | ||||
|       </Stack> | ||||
|  | ||||
|       <Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}> | ||||
|         Текущий масштаб: {Math.round(scale * SCALE_FACTOR * 100) / 100} | ||||
|       </Typography> | ||||
|  | ||||
|       <Slider | ||||
|         value={scale * SCALE_FACTOR} | ||||
|         onChange={(_, newValue) => { | ||||
|           if (typeof newValue === "number") { | ||||
|             setScaleAtCenter(newValue / SCALE_FACTOR); | ||||
|           } | ||||
|         }} | ||||
|         min={minScale} | ||||
|         max={maxScale} | ||||
|         step={0.1} | ||||
|         sx={{ | ||||
|           color: "#fff", | ||||
|           "& .MuiSlider-thumb": { | ||||
|             backgroundColor: "#fff", | ||||
|           }, | ||||
|           "& .MuiSlider-track": { | ||||
|             backgroundColor: "#fff", | ||||
|           }, | ||||
|           "& .MuiSlider-rail": { | ||||
|             backgroundColor: "#666", | ||||
|           }, | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <TextField | ||||
|         type="number" | ||||
|         label="Текущий масштаб" | ||||
|         variant="filled" | ||||
|         value={Math.round(scale * SCALE_FACTOR * 100) / 100} | ||||
|         onChange={(e) => { | ||||
|           const newScale = Number(e.target.value); | ||||
|           if ( | ||||
|             !isNaN(newScale) && | ||||
|             newScale >= minScale && | ||||
|             newScale <= maxScale | ||||
|           ) { | ||||
|             setScaleAtCenter(newScale / SCALE_FACTOR); | ||||
|           } | ||||
|         }} | ||||
|         style={{ backgroundColor: "#222", borderRadius: 4 }} | ||||
|         sx={{ | ||||
|           "& .MuiInputLabel-root": { | ||||
|             color: "#fff", | ||||
|           }, | ||||
|           "& .MuiInputBase-input": { | ||||
|             color: "#fff", | ||||
|           }, | ||||
|         }} | ||||
|         inputProps={{ | ||||
|           min: minScale, | ||||
|           max: maxScale, | ||||
|         }} | ||||
|       /> | ||||
|  | ||||
|       <TextField | ||||
|         type="number" | ||||
|         label="Поворот (в градусах)" | ||||
| @@ -181,11 +310,13 @@ export function RightSidebar() { | ||||
|           type="number" | ||||
|           label="Центр карты, широта" | ||||
|           variant="filled" | ||||
|           value={Math.round(localCenter.x * 100000) / 100000} | ||||
|           value={Math.round(localCenter.x * 1000) / 1000} | ||||
|           onChange={(e) => { | ||||
|             setIsUserEditing(true); | ||||
|             setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) })); | ||||
|             pan({ x: Number(e.target.value), y: localCenter.y }); | ||||
|           }} | ||||
|           onBlur={() => setIsUserEditing(false)} | ||||
|           style={{ backgroundColor: "#222", borderRadius: 4 }} | ||||
|           sx={{ | ||||
|             "& .MuiInputLabel-root": { | ||||
| @@ -195,16 +326,21 @@ export function RightSidebar() { | ||||
|               color: "#fff", | ||||
|             }, | ||||
|           }} | ||||
|           inputProps={{ | ||||
|             step: 0.001, | ||||
|           }} | ||||
|         /> | ||||
|         <TextField | ||||
|           type="number" | ||||
|           label="Центр карты, высота" | ||||
|           variant="filled" | ||||
|           value={Math.round(localCenter.y * 100000) / 100000} | ||||
|           value={Math.round(localCenter.y * 1000) / 1000} | ||||
|           onChange={(e) => { | ||||
|             setIsUserEditing(true); | ||||
|             setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) })); | ||||
|             pan({ x: localCenter.x, y: Number(e.target.value) }); | ||||
|           }} | ||||
|           onBlur={() => setIsUserEditing(false)} | ||||
|           style={{ backgroundColor: "#222", borderRadius: 4 }} | ||||
|           sx={{ | ||||
|             "& .MuiInputLabel-root": { | ||||
| @@ -214,6 +350,9 @@ export function RightSidebar() { | ||||
|               color: "#fff", | ||||
|             }, | ||||
|           }} | ||||
|           inputProps={{ | ||||
|             step: 0.001, | ||||
|           }} | ||||
|         /> | ||||
|       </Stack> | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { useEffect, useState } from "react"; | ||||
| import { useTransform } from "./TransformContext"; | ||||
| import { SightData } from "./types"; | ||||
| import { Assets, FederatedMouseEvent, Graphics, Texture } from "pixi.js"; | ||||
| import { Assets, FederatedMouseEvent, Texture } from "pixi.js"; | ||||
|  | ||||
| import { SIGHT_SIZE, UP_SCALE } from "./Constants"; | ||||
| import { coordinatesToLocal, localToCoordinates } from "./utils"; | ||||
| @@ -12,19 +12,21 @@ interface SightProps { | ||||
|   id: number; | ||||
| } | ||||
|  | ||||
| export function Sight({ sight, id }: Readonly<SightProps>) { | ||||
| export const Sight = ({ sight, id }: Readonly<SightProps>) => { | ||||
|   const { rotation, scale } = useTransform(); | ||||
|   const { setSightCoordinates } = useMapData(); | ||||
|   const { setSightCoordinates, setSelectedSight } = useMapData(); | ||||
|  | ||||
|   const [position, setPosition] = useState( | ||||
|     coordinatesToLocal(sight.latitude, sight.longitude) | ||||
|   ); | ||||
|   const [isDragging, setIsDragging] = useState(false); | ||||
|   const [isPointerDown, setIsPointerDown] = useState(false); | ||||
|   const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); | ||||
|   const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 }); | ||||
|  | ||||
|   const handlePointerDown = (e: FederatedMouseEvent) => { | ||||
|     setIsDragging(true); | ||||
|     setIsPointerDown(true); | ||||
|     setIsDragging(false); | ||||
|     setStartPosition({ | ||||
|       x: position.x, | ||||
|       y: position.y, | ||||
| @@ -37,7 +39,18 @@ export function Sight({ sight, id }: Readonly<SightProps>) { | ||||
|     e.stopPropagation(); | ||||
|   }; | ||||
|   const handlePointerMove = (e: FederatedMouseEvent) => { | ||||
|     if (!isDragging) return; | ||||
|     if (!isPointerDown) return; | ||||
|  | ||||
|     if (!isDragging) { | ||||
|       const dx = e.globalX - startMousePosition.x; | ||||
|       const dy = e.globalY - startMousePosition.y; | ||||
|       if (Math.abs(dx) > 2 || Math.abs(dy) > 2) { | ||||
|         setIsDragging(true); | ||||
|       } else { | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE; | ||||
|     const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE; | ||||
|     const cos = Math.cos(rotation); | ||||
| @@ -53,30 +66,37 @@ export function Sight({ sight, id }: Readonly<SightProps>) { | ||||
|   }; | ||||
|  | ||||
|   const handlePointerUp = (e: FederatedMouseEvent) => { | ||||
|     setIsPointerDown(false); | ||||
|  | ||||
|     // Если не было перетаскивания, то это клик | ||||
|     if (!isDragging) { | ||||
|       setSelectedSight(sight); | ||||
|     } | ||||
|  | ||||
|     setIsDragging(false); | ||||
|     e.stopPropagation(); | ||||
|   }; | ||||
|  | ||||
|   const [texture, setTexture] = useState(Texture.EMPTY); | ||||
|   useEffect(() => { | ||||
|     if (texture === Texture.EMPTY) { | ||||
|       Assets.load("/SightIcon.png").then((result) => { | ||||
|         setTexture(result); | ||||
|       }); | ||||
|     } | ||||
|   }, [texture]); | ||||
|     Assets.load("/SightIcon.png").then(setTexture); | ||||
|   }, []); | ||||
|  | ||||
|   function draw(g: Graphics) { | ||||
|     g.clear(); | ||||
|     g.circle(0, 0, 20); | ||||
|     g.fill({ color: "#000" }); // Fill circle with primary color | ||||
|   } | ||||
|   useEffect(() => { | ||||
|     console.log( | ||||
|       `Rendering Sight ${id + 1} at [${sight.latitude}, ${sight.longitude}]` | ||||
|     ); | ||||
|   }, [id, sight.latitude, sight.longitude]); | ||||
|  | ||||
|   if (!sight) { | ||||
|     console.error("sight is null"); | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   // Компенсируем масштаб для сохранения постоянного размера | ||||
|   const compensatedSize = SIGHT_SIZE / scale; | ||||
|   const compensatedFontSize = 24 / scale; | ||||
|  | ||||
|   return ( | ||||
|     <pixiContainer | ||||
|       rotation={-rotation} | ||||
| @@ -86,22 +106,34 @@ export function Sight({ sight, id }: Readonly<SightProps>) { | ||||
|       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 | ||||
|       x={position.x * UP_SCALE - SIGHT_SIZE / 2} | ||||
|       y={position.y * UP_SCALE - SIGHT_SIZE / 2} | ||||
|     > | ||||
|       <pixiSprite texture={texture} width={SIGHT_SIZE} height={SIGHT_SIZE} /> | ||||
|       <pixiGraphics draw={draw} x={SIGHT_SIZE} y={0} /> | ||||
|       <pixiSprite | ||||
|         texture={texture} | ||||
|         width={compensatedSize} | ||||
|         height={compensatedSize} | ||||
|       /> | ||||
|       <pixiGraphics | ||||
|         draw={(g) => { | ||||
|           g.clear(); | ||||
|           g.circle(0, 0, 20 / scale); | ||||
|           g.fill({ color: "#000" }); | ||||
|         }} | ||||
|         x={compensatedSize} | ||||
|         y={0} | ||||
|       /> | ||||
|       <pixiText | ||||
|         text={`${id + 1}`} | ||||
|         x={SIGHT_SIZE + 1} | ||||
|         x={compensatedSize + 1 / scale} | ||||
|         y={0} | ||||
|         anchor={0.5} | ||||
|         style={{ | ||||
|           fontSize: 24, | ||||
|           fontSize: compensatedFontSize, | ||||
|           fontWeight: "bold", | ||||
|           fill: "#ffffff", | ||||
|         }} | ||||
|       /> | ||||
|     </pixiContainer> | ||||
|   ); | ||||
| } | ||||
| }; | ||||
|   | ||||
							
								
								
									
										60
									
								
								src/pages/Route/route-preview/SightInfoWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/pages/Route/route-preview/SightInfoWidget.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| import { Box, Typography, IconButton } from "@mui/material"; | ||||
| import { Close } from "@mui/icons-material"; | ||||
| import { useMapData } from "./MapDataContext"; | ||||
|  | ||||
| export function SightInfoWidget() { | ||||
|   const { selectedSight, setSelectedSight } = useMapData(); | ||||
|  | ||||
|   if (!selectedSight) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Box | ||||
|       sx={{ | ||||
|         position: "absolute", | ||||
|         bottom: 16, | ||||
|         left: "50%", | ||||
|         transform: "translateX(-50%)", | ||||
|         backgroundColor: "rgba(0, 0, 0, 0.9)", | ||||
|         color: "white", | ||||
|         padding: "12px 16px", | ||||
|         borderRadius: "4px", | ||||
|         minWidth: 250, | ||||
|         maxWidth: 400, | ||||
|         backdropFilter: "blur(10px)", | ||||
|         border: "1px solid rgba(255, 255, 255, 0.2)", | ||||
|         zIndex: 1000, | ||||
|         boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)", | ||||
|       }} | ||||
|     > | ||||
|       <Box | ||||
|         sx={{ | ||||
|           display: "flex", | ||||
|           justifyContent: "space-between", | ||||
|           alignItems: "flex-start", | ||||
|           mb: 1, | ||||
|         }} | ||||
|       > | ||||
|         <Typography variant="h6" sx={{ fontWeight: "bold", color: "#fff" }}> | ||||
|           {selectedSight.name} | ||||
|         </Typography> | ||||
|         <IconButton | ||||
|           size="small" | ||||
|           onClick={() => setSelectedSight(undefined)} | ||||
|           sx={{ color: "#fff", p: 0, minWidth: 24, width: 24, height: 24 }} | ||||
|         > | ||||
|           <Close fontSize="small" /> | ||||
|         </IconButton> | ||||
|       </Box> | ||||
|  | ||||
|       <Typography variant="body2" sx={{ color: "#ccc", mb: 1 }}> | ||||
|         {selectedSight.address} | ||||
|       </Typography> | ||||
|  | ||||
|       <Typography variant="caption" sx={{ color: "#999" }}> | ||||
|         Город: {selectedSight.city} | ||||
|       </Typography> | ||||
|     </Box> | ||||
|   ); | ||||
| } | ||||
| @@ -1,4 +1,8 @@ | ||||
| import { FederatedMouseEvent, Graphics } from "pixi.js"; | ||||
| import { useCallback, useState, useEffect, useRef, FC } from "react"; | ||||
| import { observer } from "mobx-react-lite"; | ||||
|  | ||||
| // --- Заглушки для зависимостей (замените на ваши реальные импорты) --- | ||||
| import { | ||||
|   BACKGROUND_COLOR, | ||||
|   PATH_COLOR, | ||||
| @@ -7,140 +11,545 @@ import { | ||||
|   UP_SCALE, | ||||
| } from "./Constants"; | ||||
| import { useTransform } from "./TransformContext"; | ||||
| import { useCallback, 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"; | ||||
| // --- Конец заглушек --- | ||||
|  | ||||
| // --- Декларации для react-pixi --- | ||||
| // (В реальном проекте типы должны быть предоставлены библиотекой @pixi/react-pixi) | ||||
| declare const pixiContainer: any; | ||||
| declare const pixiGraphics: any; | ||||
| declare const pixiText: any; | ||||
|  | ||||
| // --- Типы --- | ||||
| type HorizontalAlign = "left" | "center" | "right"; | ||||
| type VerticalAlign = "top" | "center" | "bottom"; | ||||
| type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`; | ||||
| type LabelAlign = "left" | "center" | "right"; | ||||
|  | ||||
| // --- Утилиты --- | ||||
|  | ||||
| /** | ||||
|  * Преобразует текстовое позиционирование в anchor координаты. | ||||
|  */ | ||||
| const getAnchorFromTextAlign = (align: TextAlign): { x: number; y: number } => { | ||||
|   const parts = align.split(" "); | ||||
|   const horizontal = parts[0] as HorizontalAlign; | ||||
|   const vertical = (parts[1] as VerticalAlign) || "center"; | ||||
|   const horizontalMap: Record<HorizontalAlign, number> = { | ||||
|     left: 0, | ||||
|     center: 0.5, | ||||
|     right: 1, | ||||
|   }; | ||||
|   const verticalMap: Record<VerticalAlign, number> = { | ||||
|     top: 0, | ||||
|     center: 0.5, | ||||
|     bottom: 1, | ||||
|   }; | ||||
|   return { x: horizontalMap[horizontal], y: verticalMap[vertical] }; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Получает координату anchor.x из типа выравнивания. | ||||
|  */ | ||||
|  | ||||
| // --- Интерфейсы пропсов --- | ||||
|  | ||||
| interface StationProps { | ||||
|   station: StationData; | ||||
|   ruLabel: string | null; | ||||
|   anchorPoint?: { x: number; y: number }; | ||||
|   /** Anchor для всего блока с текстом. По умолчанию: `"right center"` */ | ||||
|   labelBlockAnchor?: TextAlign | { x: number; y: number }; | ||||
|   /** Внутреннее выравнивание текста в блоке. По умолчанию: `"left"` */ | ||||
|   labelAlign?: LabelAlign; | ||||
|   /** Callback для изменения внутреннего выравнивания */ | ||||
|   onLabelAlignChange?: (align: LabelAlign) => void; | ||||
| } | ||||
|  | ||||
| export const Station = observer( | ||||
|   ({ station, ruLabel }: Readonly<StationProps>) => { | ||||
|     const draw = useCallback((g: Graphics) => { | ||||
| interface LabelAlignmentControlProps { | ||||
|   scale: number; | ||||
|   currentAlign: LabelAlign; | ||||
|   onAlignChange: (align: LabelAlign) => void; | ||||
|   onPointerOver: () => void; | ||||
|   onPointerOut: () => void; | ||||
|   onControlPointerEnter: () => void; | ||||
|   onControlPointerLeave: () => void; | ||||
| } | ||||
|  | ||||
| interface StationLabelProps | ||||
|   extends Omit<StationProps, "ruLabelAnchor" | "nameLabelAnchor"> {} | ||||
|  | ||||
| // ========================================================================= | ||||
| // Компонент: Панель управления выравниванием в стиле УрФУ | ||||
| // ========================================================================= | ||||
|  | ||||
| const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({ | ||||
|   scale, | ||||
|   currentAlign, | ||||
|   onAlignChange, | ||||
|  | ||||
|   onControlPointerEnter, | ||||
|   onControlPointerLeave, | ||||
| }) => { | ||||
|   const controlHeight = 50 / scale; | ||||
|   const controlWidth = 200 / scale; | ||||
|   const fontSize = 18 / scale; | ||||
|   const borderRadius = 8 / scale; | ||||
|   const compensatedRuFontSize = (26 * 0.75) / scale; | ||||
|   const buttonWidth = controlWidth / 3; | ||||
|   const strokeWidth = 2 / scale; | ||||
|  | ||||
|   const drawBg = useCallback( | ||||
|     (g: Graphics) => { | ||||
|       g.clear(); | ||||
|  | ||||
|       // Основной фон с градиентом | ||||
|       g.roundRect( | ||||
|         -controlWidth / 2, | ||||
|         0, | ||||
|         controlWidth, | ||||
|         controlHeight, | ||||
|         borderRadius | ||||
|       ); | ||||
|       g.fill({ color: "#1a1a1a" }); // Темный фон как у УрФУ | ||||
|  | ||||
|       // Тонкая рамка | ||||
|       g.roundRect( | ||||
|         -controlWidth / 2, | ||||
|         0, | ||||
|         controlWidth, | ||||
|         controlHeight, | ||||
|         borderRadius | ||||
|       ); | ||||
|       g.stroke({ color: "#333333", width: strokeWidth }); | ||||
|  | ||||
|       // Разделители между кнопками | ||||
|       for (let i = 1; i < 3; i++) { | ||||
|         const x = -controlWidth / 2 + buttonWidth * i; | ||||
|         g.moveTo(x, strokeWidth); | ||||
|         g.lineTo(x, controlHeight - strokeWidth); | ||||
|         g.stroke({ color: "#333333", width: strokeWidth }); | ||||
|       } | ||||
|     }, | ||||
|     [controlWidth, controlHeight, borderRadius, buttonWidth, strokeWidth] | ||||
|   ); | ||||
|  | ||||
|   const drawButtonHighlight = useCallback( | ||||
|     (g: Graphics, index: number, isActive: boolean) => { | ||||
|       g.clear(); | ||||
|  | ||||
|       if (isActive) { | ||||
|         const x = -controlWidth / 2 + buttonWidth * index; | ||||
|         g.roundRect( | ||||
|           x + strokeWidth, | ||||
|           strokeWidth, | ||||
|           buttonWidth - strokeWidth * 2, | ||||
|           controlHeight - strokeWidth * 2, | ||||
|           borderRadius / 2 | ||||
|         ); | ||||
|         g.fill({ color: "#0066cc", alpha: 0.8 }); // Синий акцент УрФУ | ||||
|       } | ||||
|     }, | ||||
|     [controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius] | ||||
|   ); | ||||
|  | ||||
|   const getTextStyle = (isActive: boolean) => ({ | ||||
|     fontSize, | ||||
|     fontWeight: isActive ? ("bold" as const) : ("normal" as const), | ||||
|     fill: isActive ? "#ffffff" : "#cccccc", | ||||
|     fontFamily: "Arial, sans-serif", | ||||
|   }); | ||||
|  | ||||
|   const alignOptions = [ | ||||
|     { key: "left" as const, label: "Left" }, | ||||
|     { key: "center" as const, label: "Center" }, | ||||
|     { key: "right" as const, label: "Right" }, | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <pixiContainer | ||||
|       position={{ x: 0, y: compensatedRuFontSize * 1.1 + 15 / scale }} | ||||
|       eventMode="static" | ||||
|       onPointerOver={(e: FederatedMouseEvent) => { | ||||
|         e.stopPropagation(); | ||||
|         onControlPointerEnter(); | ||||
|       }} | ||||
|       onPointerOut={(e: FederatedMouseEvent) => { | ||||
|         e.stopPropagation(); | ||||
|         onControlPointerLeave(); | ||||
|       }} | ||||
|       onPointerDown={(e: FederatedMouseEvent) => { | ||||
|         e.stopPropagation(); | ||||
|       }} | ||||
|     > | ||||
|       {/* Основной фон */} | ||||
|       <pixiGraphics draw={drawBg} /> | ||||
|  | ||||
|       {/* Кнопки с подсветкой */} | ||||
|       {alignOptions.map((option, index) => ( | ||||
|         <pixiContainer key={option.key}> | ||||
|           {/* Подсветка активной кнопки */} | ||||
|           <pixiGraphics | ||||
|             draw={(g: Graphics) => | ||||
|               drawButtonHighlight(g, index, option.key === currentAlign) | ||||
|             } | ||||
|           /> | ||||
|  | ||||
|           {/* Текст кнопки */} | ||||
|           <pixiText | ||||
|             text={option.label} | ||||
|             anchor={{ x: 0.5, y: 0.5 }} | ||||
|             position={{ | ||||
|               x: -controlWidth / 2 + buttonWidth * (index + 0.5), | ||||
|               y: controlHeight / 2, | ||||
|             }} | ||||
|             style={getTextStyle(option.key === currentAlign)} | ||||
|             eventMode="static" | ||||
|             cursor="pointer" | ||||
|             onClick={(e: FederatedMouseEvent) => { | ||||
|               e.stopPropagation(); | ||||
|               onAlignChange(option.key); | ||||
|             }} | ||||
|             onPointerDown={(e: FederatedMouseEvent) => { | ||||
|               e.stopPropagation(); | ||||
|               onAlignChange(option.key); | ||||
|             }} | ||||
|             onPointerOver={(e: FederatedMouseEvent) => { | ||||
|               e.stopPropagation(); | ||||
|               onControlPointerEnter(); | ||||
|             }} | ||||
|           /> | ||||
|         </pixiContainer> | ||||
|       ))} | ||||
|     </pixiContainer> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| // ========================================================================= | ||||
| // Компонент: Метка Станции (с логикой) | ||||
| // ========================================================================= | ||||
|  | ||||
| const StationLabel = observer( | ||||
|   ({ | ||||
|     station, | ||||
|     ruLabel, | ||||
|     anchorPoint, | ||||
|     labelBlockAnchor: labelBlockAnchorProp, | ||||
|     labelAlign: labelAlignProp = "center", | ||||
|     onLabelAlignChange, | ||||
|   }: Readonly<StationLabelProps>) => { | ||||
|     const { language } = languageStore; | ||||
|     const { rotation, scale } = useTransform(); | ||||
|     const { setStationOffset, setStationAlign } = useMapData(); | ||||
|  | ||||
|     const [position, setPosition] = useState({ x: 0, y: 0 }); | ||||
|     const [isDragging, setIsDragging] = useState(false); | ||||
|     const [isPointerDown, setIsPointerDown] = useState(false); | ||||
|     const [isHovered, setIsHovered] = useState(false); | ||||
|     const [isControlHovered, setIsControlHovered] = useState(false); | ||||
|     const [currentLabelAlign, setCurrentLabelAlign] = useState(labelAlignProp); | ||||
|     const [ruLabelWidth, setRuLabelWidth] = useState(0); | ||||
|  | ||||
|     const dragStartPos = useRef({ x: 0, y: 0 }); | ||||
|     const mouseStartPos = useRef({ x: 0, y: 0 }); | ||||
|     const hideTimer = useRef<NodeJS.Timeout | null>(null); | ||||
|     const ruLabelRef = useRef<any>(null); | ||||
|  | ||||
|     useEffect(() => { | ||||
|       return () => { | ||||
|         if (hideTimer.current) { | ||||
|           clearTimeout(hideTimer.current); | ||||
|         } | ||||
|       }; | ||||
|     }, []); | ||||
|  | ||||
|     const handlePointerEnter = () => { | ||||
|       if (hideTimer.current) { | ||||
|         clearTimeout(hideTimer.current); | ||||
|         hideTimer.current = null; | ||||
|       } | ||||
|       setIsHovered(true); | ||||
|     }; | ||||
|  | ||||
|     const handleControlPointerEnter = () => { | ||||
|       // Дополнительная обработка для панели управления | ||||
|       if (hideTimer.current) { | ||||
|         clearTimeout(hideTimer.current); | ||||
|         hideTimer.current = null; | ||||
|       } | ||||
|       setIsControlHovered(true); | ||||
|       setIsHovered(true); | ||||
|     }; | ||||
|  | ||||
|     const handleControlPointerLeave = () => { | ||||
|       setIsControlHovered(false); | ||||
|       // Если курсор не над основным контейнером, скрываем панель через некоторое время | ||||
|       if (!isHovered) { | ||||
|         hideTimer.current = setTimeout(() => { | ||||
|           setIsHovered(false); | ||||
|         }, 0); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     const handlePointerLeave = () => { | ||||
|       // Увеличиваем время до скрытия панели и добавляем проверку | ||||
|       hideTimer.current = setTimeout(() => { | ||||
|         setIsHovered(false); | ||||
|         // Если курсор не над панелью управления, скрываем и её | ||||
|         if (!isControlHovered) { | ||||
|           setIsControlHovered(false); | ||||
|         } | ||||
|       }, 100); // Увеличиваем время до скрытия панели | ||||
|     }; | ||||
|  | ||||
|     useEffect(() => { | ||||
|       setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 }); | ||||
|     }, [station.offset_x, station.offset_y, station.id]); | ||||
|  | ||||
|     // Функция для конвертации числового align в строковый | ||||
|     const convertNumericAlign = (align: number): LabelAlign => { | ||||
|       switch (align) { | ||||
|         case 0: | ||||
|           return "left"; | ||||
|         case 1: | ||||
|           return "center"; | ||||
|         case 2: | ||||
|           return "right"; | ||||
|         default: | ||||
|           return "center"; | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     // Функция для конвертации строкового align в числовой | ||||
|     const convertStringAlign = (align: LabelAlign): number => { | ||||
|       switch (align) { | ||||
|         case "left": | ||||
|           return 0; | ||||
|         case "center": | ||||
|           return 1; | ||||
|         case "right": | ||||
|           return 2; | ||||
|         default: | ||||
|           return 1; | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     useEffect(() => { | ||||
|       setCurrentLabelAlign(convertNumericAlign(station.align ?? 1)); | ||||
|     }, [station.align]); | ||||
|  | ||||
|     if (!station) return null; | ||||
|  | ||||
|     const coordinates = coordinatesToLocal(station.latitude, station.longitude); | ||||
|     const compensatedRuFontSize = (26 * 0.75) / scale; | ||||
|     const compensatedNameFontSize = (16 * 0.75) / scale; | ||||
|     const minDistance = 30; | ||||
|     const compensatedOffset = Math.max(minDistance / scale, 24 / scale); | ||||
|     const textBlockPosition = anchorPoint || position; | ||||
|     const finalLabelBlockAnchor = labelBlockAnchorProp || "right center"; | ||||
|     const labelBlockAnchor = | ||||
|       typeof finalLabelBlockAnchor === "string" | ||||
|         ? getAnchorFromTextAlign(finalLabelBlockAnchor) | ||||
|         : finalLabelBlockAnchor; | ||||
|  | ||||
|     // Измеряем ширину верхнего лейбла | ||||
|     useEffect(() => { | ||||
|       if (ruLabelRef.current && ruLabel) { | ||||
|         setRuLabelWidth(ruLabelRef.current.width); | ||||
|       } | ||||
|     }, [ruLabel, compensatedRuFontSize]); | ||||
|  | ||||
|     const handlePointerDown = (e: FederatedMouseEvent) => { | ||||
|       setIsPointerDown(true); | ||||
|       setIsDragging(false); | ||||
|       dragStartPos.current = { ...position }; | ||||
|       mouseStartPos.current = { x: e.global.x, y: e.global.y }; | ||||
|       e.stopPropagation(); | ||||
|     }; | ||||
|  | ||||
|     const handlePointerMove = (e: FederatedMouseEvent) => { | ||||
|       if (!isPointerDown) return; | ||||
|       if (!isDragging) { | ||||
|         const dx = e.global.x - mouseStartPos.current.x; | ||||
|         const dy = e.global.y - mouseStartPos.current.y; | ||||
|         if (Math.hypot(dx, dy) > 3) setIsDragging(true); | ||||
|         else return; | ||||
|       } | ||||
|       const dx = (e.global.x - mouseStartPos.current.x) / scale; | ||||
|       const dy = (e.global.y - mouseStartPos.current.y) / scale; | ||||
|       const newPosition = { | ||||
|         x: dragStartPos.current.x + dx, | ||||
|         y: dragStartPos.current.y + dy, | ||||
|       }; | ||||
|  | ||||
|       // Проверяем, изменилась ли позиция | ||||
|       if ( | ||||
|         Math.abs(newPosition.x - position.x) > 0.01 || | ||||
|         Math.abs(newPosition.y - position.y) > 0.01 | ||||
|       ) { | ||||
|         setPosition(newPosition); | ||||
|         setStationOffset(station.id, newPosition.x, newPosition.y); | ||||
|       } | ||||
|       e.stopPropagation(); | ||||
|     }; | ||||
|  | ||||
|     const handlePointerUp = (e: FederatedMouseEvent) => { | ||||
|       setIsPointerDown(false); | ||||
|       setTimeout(() => setIsDragging(false), 50); | ||||
|       e.stopPropagation(); | ||||
|     }; | ||||
|  | ||||
|     const handleAlignChange = async (align: LabelAlign) => { | ||||
|       setCurrentLabelAlign(align); | ||||
|       onLabelAlignChange?.(align); | ||||
|       // Сохраняем в стор | ||||
|       const numericAlign = convertStringAlign(align); | ||||
|       setStationAlign(station.id, numericAlign); | ||||
|     }; | ||||
|  | ||||
|     // Функция для расчета позиции нижнего лейбла относительно ширины верхнего | ||||
|     const getSecondLabelPosition = (): number => { | ||||
|       if (!ruLabelWidth) return 0; | ||||
|  | ||||
|       switch (currentLabelAlign) { | ||||
|         case "left": | ||||
|           // Позиционируем относительно левого края верхнего текста | ||||
|           return -ruLabelWidth / 2; | ||||
|         case "center": | ||||
|           // Центрируем относительно центра верхнего текста | ||||
|           return 0; | ||||
|         case "right": | ||||
|           // Позиционируем относительно правого края верхнего текста | ||||
|           return ruLabelWidth / 2; | ||||
|         default: | ||||
|           return 0; | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     // Функция для расчета anchor нижнего лейбла | ||||
|     const getSecondLabelAnchor = (): number => { | ||||
|       switch (currentLabelAlign) { | ||||
|         case "left": | ||||
|           return 0; // anchor.x = 0 (левый край) | ||||
|         case "center": | ||||
|           return 0.5; // anchor.x = 0.5 (центр) | ||||
|         case "right": | ||||
|           return 1; // anchor.x = 1 (правый край) | ||||
|         default: | ||||
|           return 0.5; | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|       <pixiContainer | ||||
|         x={coordinates.x * UP_SCALE} | ||||
|         y={coordinates.y * UP_SCALE} | ||||
|         rotation={-rotation} | ||||
|         eventMode="static" | ||||
|         interactive | ||||
|         cursor={isDragging ? "grabbing" : "grab"} | ||||
|         onPointerOver={handlePointerEnter} | ||||
|         onPointerOut={handlePointerLeave} | ||||
|         onPointerDown={handlePointerDown} | ||||
|         onPointerUp={handlePointerUp} | ||||
|         onPointerUpOutside={handlePointerUp} | ||||
|         onGlobalPointerMove={handlePointerMove} | ||||
|       > | ||||
|         <pixiContainer | ||||
|           position={{ | ||||
|             x: | ||||
|               textBlockPosition.x + | ||||
|               compensatedOffset * (labelBlockAnchor.x === 1 ? -1 : 1), | ||||
|             y: textBlockPosition.y, | ||||
|           }} | ||||
|           anchor={labelBlockAnchor} | ||||
|         > | ||||
|           {ruLabel && ( | ||||
|             <pixiText | ||||
|               ref={ruLabelRef} | ||||
|               text={ruLabel} | ||||
|               position={{ x: 0, y: 0 }} | ||||
|               anchor={{ x: 0.5, y: 0.5 }} | ||||
|               style={{ | ||||
|                 fontSize: compensatedRuFontSize, | ||||
|                 fontWeight: "bold", | ||||
|                 fill: "#ffffff", | ||||
|               }} | ||||
|             /> | ||||
|           )} | ||||
|           {station.name && language !== "ru" && ruLabel && ( | ||||
|             <pixiText | ||||
|               text={station.name} | ||||
|               position={{ | ||||
|                 x: getSecondLabelPosition(), | ||||
|                 y: compensatedRuFontSize * 1.1, | ||||
|               }} | ||||
|               anchor={{ x: getSecondLabelAnchor(), y: 0.5 }} | ||||
|               style={{ | ||||
|                 fontSize: compensatedNameFontSize, | ||||
|                 fontWeight: "bold", | ||||
|                 fill: "#CCCCCC", | ||||
|               }} | ||||
|             /> | ||||
|           )} | ||||
|           {(isHovered || isControlHovered) && !isDragging && ( | ||||
|             <LabelAlignmentControl | ||||
|               scale={scale} | ||||
|               currentAlign={currentLabelAlign} | ||||
|               onAlignChange={handleAlignChange} | ||||
|               onPointerOver={handlePointerEnter} | ||||
|               onPointerOut={handlePointerLeave} | ||||
|               onControlPointerEnter={handleControlPointerEnter} | ||||
|               onControlPointerLeave={handleControlPointerLeave} | ||||
|             /> | ||||
|           )} | ||||
|         </pixiContainer> | ||||
|       </pixiContainer> | ||||
|     ); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| // ========================================================================= | ||||
| // Главный экспортируемый компонент: Станция | ||||
| // ========================================================================= | ||||
|  | ||||
| export const Station = ({ | ||||
|   station, | ||||
|   ruLabel, | ||||
|   anchorPoint, | ||||
|   labelBlockAnchor, | ||||
|   labelAlign, | ||||
|   onLabelAlignChange, | ||||
| }: 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 | ||||
|       ); | ||||
|       const radius = STATION_RADIUS; | ||||
|       g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius); | ||||
|       g.fill({ color: PATH_COLOR }); | ||||
|       g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH }); | ||||
|     }, []); | ||||
|     }, | ||||
|     [station.latitude, station.longitude] | ||||
|   ); | ||||
|  | ||||
|     return ( | ||||
|       <pixiContainer> | ||||
|         <pixiGraphics draw={draw} /> | ||||
|         <StationLabel station={station} ruLabel={ruLabel} /> | ||||
|       </pixiContainer> | ||||
|     ); | ||||
|   } | ||||
| ); | ||||
|  | ||||
| export const StationLabel = observer( | ||||
|   ({ station, ruLabel }: Readonly<StationProps>) => { | ||||
|     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> | ||||
|     ); | ||||
|   } | ||||
| ); | ||||
|   return ( | ||||
|     <pixiContainer> | ||||
|       <pixiGraphics draw={draw} /> | ||||
|       <StationLabel | ||||
|         station={station} | ||||
|         ruLabel={ruLabel} | ||||
|         anchorPoint={anchorPoint} | ||||
|         labelBlockAnchor={labelBlockAnchor} | ||||
|         labelAlign={labelAlign} | ||||
|         onLabelAlignChange={onLabelAlignChange} | ||||
|       /> | ||||
|     </pixiContainer> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -26,9 +26,12 @@ const TransformContext = createContext<{ | ||||
|     rotationDegrees?: number, | ||||
|     scale?: number | ||||
|   ) => void; | ||||
|   setScaleOnly: (newScale: number) => void; | ||||
|   setScaleWithoutMovingCenter: (newScale: number) => void; | ||||
|   setScreenCenter: React.Dispatch< | ||||
|     React.SetStateAction<{ x: number; y: number } | undefined> | ||||
|   >; | ||||
|   setScaleAtCenter: (newScale: number) => void; | ||||
| }>({ | ||||
|   position: { x: 0, y: 0 }, | ||||
|   scale: 1, | ||||
| @@ -41,7 +44,10 @@ const TransformContext = createContext<{ | ||||
|   localToScreen: () => ({ x: 0, y: 0 }), | ||||
|   rotateToAngle: () => {}, | ||||
|   setTransform: () => {}, | ||||
|   setScaleOnly: () => {}, | ||||
|   setScaleWithoutMovingCenter: () => {}, | ||||
|   setScreenCenter: () => {}, | ||||
|   setScaleAtCenter: () => {}, | ||||
| }); | ||||
|  | ||||
| // Provider component | ||||
| @@ -136,8 +142,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => { | ||||
|         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, | ||||
| @@ -160,6 +164,37 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => { | ||||
|     [rotation, scale, screenCenter] | ||||
|   ); | ||||
|  | ||||
|   const setScaleAtCenter = useCallback( | ||||
|     (newScale: number) => { | ||||
|       if (scale === newScale) return; | ||||
|  | ||||
|       const center = screenCenter ?? { x: 0, y: 0 }; | ||||
|  | ||||
|       const actualZoomFactor = newScale / scale; | ||||
|  | ||||
|       const newPosition = { | ||||
|         x: position.x + (center.x - position.x) * (1 - actualZoomFactor), | ||||
|         y: position.y + (center.y - position.y) * (1 - actualZoomFactor), | ||||
|       }; | ||||
|  | ||||
|       setPosition(newPosition); | ||||
|       setScale(newScale); | ||||
|     }, | ||||
|     [position, scale, screenCenter] | ||||
|   ); | ||||
|  | ||||
|   const setScaleOnly = useCallback((newScale: number) => { | ||||
|     // Изменяем только масштаб, не трогая позицию и поворот | ||||
|     setScale(newScale); | ||||
|   }, []); | ||||
|  | ||||
|   const setScaleWithoutMovingCenter = useCallback( | ||||
|     (newScale: number) => { | ||||
|       setScale(newScale); | ||||
|     }, | ||||
|     [setScale] | ||||
|   ); | ||||
|  | ||||
|   const value = useMemo( | ||||
|     () => ({ | ||||
|       position, | ||||
| @@ -173,17 +208,25 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => { | ||||
|       screenToLocal, | ||||
|       localToScreen, | ||||
|       setTransform, | ||||
|       setScaleOnly, | ||||
|       setScaleWithoutMovingCenter, | ||||
|       setScreenCenter, | ||||
|       setScaleAtCenter, | ||||
|     }), | ||||
|     [ | ||||
|       position, | ||||
|       scale, | ||||
|       rotation, | ||||
|       screenCenter, | ||||
|       setScale, | ||||
|       rotateToAngle, | ||||
|       screenToLocal, | ||||
|       localToScreen, | ||||
|       setTransform, | ||||
|       setScaleOnly, | ||||
|       setScaleWithoutMovingCenter, | ||||
|       setScreenCenter, | ||||
|       setScaleAtCenter, | ||||
|     ] | ||||
|   ); | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,11 @@ | ||||
| import { Stack, Typography } from "@mui/material"; | ||||
| import { Stack, Typography, Box, IconButton } from "@mui/material"; | ||||
| import { Close } from "@mui/icons-material"; | ||||
| import { Landmark } from "lucide-react"; | ||||
| import { useMapData } from "./MapDataContext"; | ||||
|  | ||||
| export function Widgets() { | ||||
|   const { selectedSight, setSelectedSight } = useMapData(); | ||||
|  | ||||
|   return ( | ||||
|     <Stack | ||||
|       direction="column" | ||||
| @@ -24,6 +29,8 @@ export function Widgets() { | ||||
|           Станция | ||||
|         </Typography> | ||||
|       </Stack> | ||||
|  | ||||
|       {/* Виджет выбранной достопримечательности (заменяет виджет погоды) */} | ||||
|       <Stack | ||||
|         bgcolor="primary.main" | ||||
|         width={223} | ||||
| @@ -31,12 +38,102 @@ export function Widgets() { | ||||
|         p={2} | ||||
|         m={2} | ||||
|         borderRadius={2} | ||||
|         alignItems="center" | ||||
|         justifyContent="center" | ||||
|         sx={{ | ||||
|           pointerEvents: "auto", | ||||
|           position: "relative", | ||||
|           overflow: "hidden", | ||||
|         }} | ||||
|       > | ||||
|         <Typography variant="h6" sx={{ color: "#fff" }}> | ||||
|           Погода | ||||
|         </Typography> | ||||
|         {selectedSight ? ( | ||||
|           <Box | ||||
|             sx={{ height: "100%", display: "flex", flexDirection: "column" }} | ||||
|           > | ||||
|             {/* Заголовок с кнопкой закрытия */} | ||||
|             <Box | ||||
|               sx={{ | ||||
|                 display: "flex", | ||||
|                 justifyContent: "space-between", | ||||
|                 alignItems: "flex-start", | ||||
|                 mb: 1, | ||||
|               }} | ||||
|             > | ||||
|               <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}> | ||||
|                 <Landmark size={16} /> | ||||
|                 <Typography | ||||
|                   variant="subtitle2" | ||||
|                   sx={{ color: "#fff", fontWeight: "bold" }} | ||||
|                 > | ||||
|                   {selectedSight.name} | ||||
|                 </Typography> | ||||
|               </Box> | ||||
|               <IconButton | ||||
|                 size="small" | ||||
|                 onClick={() => setSelectedSight(undefined)} | ||||
|                 sx={{ | ||||
|                   color: "#fff", | ||||
|                   p: 0, | ||||
|                   minWidth: 20, | ||||
|                   width: 20, | ||||
|                   height: 20, | ||||
|                   "&:hover": { backgroundColor: "rgba(255, 255, 255, 0.1)" }, | ||||
|                 }} | ||||
|               > | ||||
|                 <Close fontSize="small" /> | ||||
|               </IconButton> | ||||
|             </Box> | ||||
|  | ||||
|             {/* Описание достопримечательности */} | ||||
|             {selectedSight.address && ( | ||||
|               <Typography | ||||
|                 variant="caption" | ||||
|                 sx={{ | ||||
|                   color: "#fff", | ||||
|                   mb: 1, | ||||
|                   opacity: 0.9, | ||||
|                   lineHeight: 1.3, | ||||
|                   overflow: "hidden", | ||||
|                   textOverflow: "ellipsis", | ||||
|                   display: "-webkit-box", | ||||
|                   WebkitLineClamp: 3, | ||||
|                   WebkitBoxOrient: "vertical", | ||||
|                 }} | ||||
|               > | ||||
|                 {selectedSight.address} | ||||
|               </Typography> | ||||
|             )} | ||||
|  | ||||
|             {/* Город */} | ||||
|             {selectedSight.city && ( | ||||
|               <Typography | ||||
|                 variant="caption" | ||||
|                 sx={{ | ||||
|                   color: "#fff", | ||||
|                   opacity: 0.7, | ||||
|                   mt: "auto", | ||||
|                 }} | ||||
|               > | ||||
|                 Город: {selectedSight.city} | ||||
|               </Typography> | ||||
|             )} | ||||
|           </Box> | ||||
|         ) : ( | ||||
|           <Box | ||||
|             sx={{ | ||||
|               height: "100%", | ||||
|               display: "flex", | ||||
|               flexDirection: "column", | ||||
|               alignItems: "center", | ||||
|               gap: 5, | ||||
|               justifyContent: "center", | ||||
|               textAlign: "center", | ||||
|             }} | ||||
|           > | ||||
|             <Landmark size={32} /> | ||||
|             <Typography variant="body2" sx={{ color: "#fff", opacity: 0.8 }}> | ||||
|               Выберите достопримечательность | ||||
|             </Typography> | ||||
|           </Box> | ||||
|         )} | ||||
|       </Stack> | ||||
|     </Stack> | ||||
|   ); | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { useRef, useEffect, useState } from "react"; | ||||
|  | ||||
| import { Widgets } from "./Widgets"; | ||||
| import { Application, extend } from "@pixi/react"; | ||||
| import { | ||||
|   Container, | ||||
| @@ -14,16 +14,18 @@ import { MapDataProvider, useMapData } from "./MapDataContext"; | ||||
| import { TransformProvider, useTransform } from "./TransformContext"; | ||||
| import { InfiniteCanvas } from "./InfiniteCanvas"; | ||||
|  | ||||
| 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"; | ||||
| import { Sight } from "./Sight"; | ||||
| import { SightData } from "./types"; | ||||
| import { Station } from "./Station"; | ||||
| import { UP_SCALE } from "./Constants"; | ||||
|  | ||||
| extend({ | ||||
|   Container, | ||||
| @@ -43,8 +45,8 @@ export const RoutePreview = () => { | ||||
|  | ||||
|           <LeftSidebar /> | ||||
|           <Stack direction="row" flex={1} position="relative" height="100%"> | ||||
|             <Widgets /> | ||||
|             <RouteMap /> | ||||
|             <Widgets /> | ||||
|             <RightSidebar /> | ||||
|           </Stack> | ||||
|         </Stack> | ||||
| @@ -55,15 +57,27 @@ export const RoutePreview = () => { | ||||
|  | ||||
| export const RouteMap = observer(() => { | ||||
|   const { language } = languageStore; | ||||
|   const { setPosition, screenToLocal, setTransform, screenCenter } = | ||||
|     useTransform(); | ||||
|   const { routeData, stationData, sightData, originalRouteData } = useMapData(); | ||||
|   console.log(stationData); | ||||
|   const { setPosition, setTransform, screenCenter } = useTransform(); | ||||
|   const { | ||||
|     routeData, | ||||
|     stationData, | ||||
|     sightData, | ||||
|     originalRouteData, | ||||
|     originalSightData, | ||||
|   } = useMapData(); | ||||
|  | ||||
|   const [points, setPoints] = useState<{ x: number; y: number }[]>([]); | ||||
|   const [isSetup, setIsSetup] = useState(false); | ||||
|  | ||||
|   const parentRef = useRef<HTMLDivElement>(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     document.body.style.overflow = "hidden"; | ||||
|     return () => { | ||||
|       document.body.style.overflow = "auto"; | ||||
|     }; | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (originalRouteData) { | ||||
|       const path = originalRouteData?.path; | ||||
| @@ -146,20 +160,14 @@ export const RouteMap = observer(() => { | ||||
|               key={obj.id} | ||||
|               ruLabel={ | ||||
|                 language === "ru" | ||||
|                   ? stationData.en[index].name | ||||
|                   ? stationData.ru[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"); | ||||
|             }} | ||||
|           /> | ||||
|           {originalSightData?.map((sight: SightData, index: number) => { | ||||
|             return <Sight sight={sight} id={index} key={sight.id} />; | ||||
|           })} | ||||
|         </InfiniteCanvas> | ||||
|       </Application> | ||||
|     </div> | ||||
|   | ||||
| @@ -1,69 +1,72 @@ | ||||
| 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; | ||||
|   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; | ||||
|   thumbnail?: string; // uuid логотипа маршрута | ||||
| } | ||||
|  | ||||
| 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; | ||||
|   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; | ||||
|   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; | ||||
|   align: number; | ||||
| } | ||||
|  | ||||
| export interface StationPatchData { | ||||
| 	station_id: number; | ||||
| 	offset_x: number; | ||||
| 	offset_y: number; | ||||
| 	transfers: StationTransferData; | ||||
|   station_id: number; | ||||
|   offset_x: number; | ||||
|   offset_y: number; | ||||
|   align: number; | ||||
|   transfers: StationTransferData; | ||||
| } | ||||
|  | ||||
| export interface SightPatchData { | ||||
| 	sight_id: number; | ||||
| 	latitude: number; | ||||
| 	longitude: number; | ||||
|   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 | ||||
| } | ||||
|   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 | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user