This commit is contained in:
Spynder
2025-05-03 01:17:37 +03:00
parent dc483d62de
commit 275eef597b
21 changed files with 2259 additions and 200 deletions

View File

@ -58,6 +58,7 @@ import {
VehicleShow,
} from "./pages/vehicle";
import { RouteList, RouteCreate, RouteEdit, RouteShow } from "./pages/route";
import { RoutePreview } from "./pages/route-preview";
import { UserList, UserCreate, UserEdit, UserShow } from "./pages/user";
import {
@ -201,6 +202,15 @@ function App() {
icon: <RouteIcon />,
},
},
{
name: "route-preview",
list: "/route",
show: "/route/:id/station",
meta: {
hide: true,
stations: "route/:id/station"
},
},
{
name: "user",
list: "/user",
@ -223,6 +233,10 @@ function App() {
>
<KBarProvider>
<Routes>
<Route path="/route-preview">
<Route path=":id" element={<RoutePreview />} />
</Route>
<Route
element={
<Authenticated

View File

@ -1,7 +1,7 @@
import {createTheme} from '@mui/material/styles'
import {RefineThemes} from '@refinedev/mui'
const COLORS = {
export const COLORS = {
primary: '#7f6b58',
secondary: '#48989f',
}

View File

@ -0,0 +1,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 BACKGROUND_COLOR = 0x111111;
export const PATH_COLOR = 0xff4d4d;

View File

@ -0,0 +1,157 @@
import { FederatedMouseEvent, FederatedWheelEvent } from "pixi.js";
import { Component, ReactNode, useEffect, useState } from "react";
import { useTransform } from "./TransformContext";
import { useMapData } from "./MapDataContext";
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, applicationRef, getCenter } = useTransform();
const { routeData, originalRouteData } = useMapData();
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 handlePointerDown = (e: FederatedMouseEvent) => {
setIsDragging(true);
setStartPosition({
x: position.x,
y: position.y
});
setStartMousePosition({
x: e.globalX,
y: e.globalY
});
setStartRotation(rotation);
e.stopPropagation();
};
useEffect(() => {
setRotation((originalRouteData?.rotate ?? 0) * Math.PI / 180);
}, [originalRouteData?.rotate]);
// Get canvas element and its dimensions/position
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isDragging) return;
if (e.shiftKey) {
const center = getCenter();
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 {
setPosition({
x: startPosition.x - startMousePosition.x + e.globalX,
y: startPosition.y - startMousePosition.y + e.globalY
});
}
e.stopPropagation();
};
// Handle mouse up
const handlePointerUp = (e: FederatedMouseEvent) => {
setIsDragging(false);
e.stopPropagation();
};
// Handle mouse wheel for zooming
const handleWheel = (e: FederatedWheelEvent) => {
e.stopPropagation();
// 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)/20;
const scaleMax = (routeData?.scale_max ?? 20)/20;
let zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in
//const newScale = scale * zoomFactor;
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
zoomFactor = newScale / scale;
if (scale === newScale) {
return;
}
// Update position to zoom towards mouse cursor
setPosition({
x: position.x + mouseX * (1 - zoomFactor),
y: position.y + mouseY * (1 - zoomFactor)
});
setScale(newScale);
};
return (
<ErrorBoundary>
{applicationRef?.current && (
<pixiGraphics
draw={(g) => {
const canvas = applicationRef.current!.getCanvas();
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>
<pixiGraphics
eventMode="none"
draw={(g) => {
g.clear();
const center = getCenter();
g.circle(center.x, center.y, 1);
g.fill("#fff");
}}
/>
</ErrorBoundary>
);
}

View File

@ -0,0 +1,33 @@
import { Stack, Typography, Button } from "@mui/material";
export function LeftSidebar() {
return (
<Stack direction="column" width="300px" p={2} bgcolor="primary.main">
<Stack direction="column" alignItems="center" justifyContent="center" my={10}>
<img src={"/Emblem.svg"} alt="logo" width={100} height={100} />
<Typography sx={{ mb: 2 }} textAlign="center">
При поддержке Правительства
Санкт-Петербурга
</Typography>
</Stack>
<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">#ВсемПоПути</Typography>
</Stack>
);
}

View File

@ -0,0 +1,187 @@
import { useCustom, useShow, useApiUrl } from "@refinedev/core";
import { useParams } from "react-router";
import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from "react";
import { RouteData, SightData, StationData, StationPatchData } from "./types";
import { axiosInstance } from "../../providers/data";
const MapDataContext = createContext<{
originalRouteData?: RouteData,
originalStationData?: StationData[],
originalSightData?: SightData[],
routeData?: RouteData,
stationData?: StationData[],
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,
saveChanges: () => void,
}>({
originalRouteData: undefined,
originalStationData: undefined,
originalSightData: undefined,
routeData: undefined,
stationData: undefined,
sightData: undefined,
isRouteLoading: true,
isStationLoading: true,
isSightLoading: true,
setScaleRange: () => {},
setMapRotation: () => {},
setMapCenter: () => {},
setStationOffset: () => {},
saveChanges: () => {},
});
export function MapDataProvider({ children }: Readonly<{ children: ReactNode }>) {
const { id: routeId } = useParams<{ id: string }>();
const apiUrl = useApiUrl();
const [originalRouteData, setOriginalRouteData] = useState<RouteData>();
const [originalStationData, setOriginalStationData] = useState<StationData[]>();
const [originalSightData, setOriginalSightData] = useState<SightData[]>();
const [routeData, setRouteData] = useState<RouteData>();
const [stationData, setStationData] = useState<StationData[]>();
const [sightData, setSightData] = useState<SightData[]>();
const [routeChanges, setRouteChanges] = useState<RouteData>({} as RouteData);
const [stationChanges, setStationChanges] = useState<StationPatchData[]>([]);
const [sightChanges, setSightChanges] = useState<SightData[]>([]);
const { data: routeQuery, isLoading: isRouteLoading } = useCustom({
url: `${apiUrl}/route/${routeId}`,
method: 'get',
});
const { data: stationQuery, isLoading: isStationLoading } = useCustom({
url: `${apiUrl}/route/${routeId}/station`,
method: 'get',
});
const { data: sightQuery, isLoading: isSightLoading } = useCustom({
url: `${apiUrl}/route/${routeId}/sight`,
method: 'get',
});
useEffect(() => {
// if not undefined, set original data
if(routeQuery?.data) setOriginalRouteData(routeQuery.data as RouteData);
if(stationQuery?.data) setOriginalStationData(stationQuery.data as StationData[]);
if(sightQuery?.data) setOriginalSightData(sightQuery.data as SightData[]);
console.log("queries", routeQuery, stationQuery, sightQuery);
}, [routeQuery, stationQuery, sightQuery]);
useEffect(() => {
// combine changes with original data
if(originalRouteData) setRouteData({...originalRouteData, ...routeChanges});
if(originalStationData) setStationData(originalStationData);
if(originalSightData) setSightData(originalSightData);
}, [
originalRouteData, originalStationData, originalSightData,
routeChanges, stationChanges, sightChanges
]);
useEffect(() => {
console.log("data", routeData, stationData, sightData);
}, [routeData, stationData, sightData]);
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() {
console.log("saveChanges", routeData);
const response = await axiosInstance.patch(`/route/${routeId}`, routeData);
saveStationChanges();
}
async function saveStationChanges() {
console.log("saveStationChanges", stationChanges);
for(const station of stationChanges) {
const response = await axiosInstance.patch(`/route/${routeId}/station`, station);
console.log("response", response);
}
}
function setStationOffset(stationId: number, x: number, y: number) {
setStationChanges((prev) => {
let found = prev.find((station) => station.station_id === stationId);
if(found) {
found.offset_x = x;
found.offset_y = y;
return prev.map((station) => {
if(station.station_id === stationId) {
return found;
}
return station;
});
} else {
const foundStation = stationData?.find((station) => station.id === stationId);
if(foundStation) {
return [...prev, {
station_id: stationId,
offset_x: x, offset_y: y,
transfers: foundStation.transfers
}];
}
return prev;
}
});
}
useEffect(() => {
console.log("stationChanges", stationChanges);
}, [stationChanges]);
const value = useMemo(() => ({
originalRouteData: originalRouteData,
originalStationData: originalStationData,
originalSightData: originalSightData,
routeData: routeData,
stationData: stationData,
sightData: sightData,
isRouteLoading,
isStationLoading,
isSightLoading,
setScaleRange,
setMapRotation,
setMapCenter,
saveChanges,
setStationOffset,
}), [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;
};

View File

@ -0,0 +1,188 @@
import { Button, Stack, TextField, Typography } from "@mui/material";
import { useMapData } from "./MapDataContext";
import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext";
export function RightSidebar() {
const { routeData, setScaleRange, saveChanges, originalRouteData, setMapRotation, setMapCenter } = useMapData();
const { rotation, setRotation, position, screenToLocal, getCenter, localToScreen, rotateToAngle, panToCoordinates } = 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 = getCenter();
const localCenter = screenToLocal(center.x, center.y);
setLocalCenter(localCenter);
}, [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}) {
panToCoordinates(x, y, rotation);
}
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 }} 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.Mui-focused': {
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}}
sx={{
'& .MuiInputLabel-root.Mui-focused': {
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.Mui-focused': {
color: "#fff"
}
}}
slotProps={{
input: {
min: 0,
max: 360
}
}}
/>
<Stack direction="row" spacing={2}>
<TextField
type="number"
label="Центр карты X"
variant="filled"
value={Math.round(localCenter.x*100000)/100000}
onChange={(e) => {
setLocalCenter(prev => ({...prev, x: Number(e.target.value)}))
pan({x: Number(e.target.value), y: localCenter.y});
}}
style={{backgroundColor: "#222", borderRadius: 4}}
sx={{
'& .MuiInputLabel-root.Mui-focused': {
color: "#fff"
}
}}
/>
<TextField
type="number"
label="Центр карты Y"
variant="filled"
value={Math.round(localCenter.y*100000)/100000}
onChange={(e) => {
setLocalCenter(prev => ({...prev, y: Number(e.target.value)}))
pan({x: localCenter.x, y: Number(e.target.value)});
}}
style={{backgroundColor: "#222", borderRadius: 4}}
sx={{
'& .MuiInputLabel-root.Mui-focused': {
color: "#fff"
}
}}
/>
</Stack>
<Button
variant="contained"
color="secondary"
sx={{ mt: 2 }}
onClick={() => {
saveChanges();
}}
>
Сохранить изменения
</Button>
</Stack>
);
}

View File

@ -0,0 +1,114 @@
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";
interface SightProps {
sight: SightData;
id: number;
}
export function Sight({
sight, id
}: Readonly<SightProps>) {
const { rotation, scale } = useTransform();
const [position, setPosition] = useState({ x: 0, y: 0 });
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;
const dy = (e.globalY - startMousePosition.y) / 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);
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;
}
return (
<pixiContainer rotation={-rotation}
eventMode='static'
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
x={sight.latitude * UP_SCALE - SIGHT_SIZE/2 + position.x} // Offset by half width to center
y={sight.longitude * UP_SCALE - SIGHT_SIZE/2 + position.y} // 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>
);
}

View File

@ -0,0 +1,103 @@
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, useState } from "react";
import { StationData } from "./types";
import { useMapData } from "./MapDataContext";
interface StationProps {
station: StationData;
}
export function Station({
station
}: Readonly<StationProps>) {
const draw = useCallback((g: Graphics) => {
g.clear();
g.circle(station.latitude * UP_SCALE, station.longitude * 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}/>
</pixiContainer>
);
}
export function StationLabel({
station
}: 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) / scale;
const dy = (e.globalY - startMousePosition.y) / 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);
setStationOffset(station.id, newPosition.x, newPosition.y);
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => {
setIsDragging(false);
e.stopPropagation();
};
return (
<pixiContainer
eventMode='static'
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
width={48}
height={48}
x={station.latitude * UP_SCALE + position.x}
y={station.longitude * UP_SCALE + position.y}
>
<pixiText
text={station.name}
rotation={-rotation}
style={{
fontSize: 48,
fontWeight: 'bold',
fill: "#ffffff"
}}
/>
</pixiContainer>
);
}

View File

@ -0,0 +1,161 @@
import { createContext, ReactNode, RefObject, useContext, useEffect, useMemo, useRef, useState } from "react";
import {
ApplicationRef
} from '@pixi/react';
import { UP_SCALE } from "./Constants";
const TransformContext = createContext<{
position: { x: number, y: number },
scale: number,
rotation: number,
applicationRef: RefObject<ApplicationRef> | null,
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 },
getCenter: () => { x: number, y: number },
rotateToAngle: (to: number, fromPosition?: {x: number, y: number}) => void,
panToCoordinates: (latitude: number, longitude: number, rotation?: number) => void
}>({
position: { x: 0, y: 0 },
scale: 1,
rotation: 0,
applicationRef: null,
setPosition: () => {},
setScale: () => {},
setRotation: () => {},
screenToLocal: () => ({ x: 0, y: 0 }),
localToScreen: () => ({ x: 0, y: 0 }),
getCenter: () => ({ x: 0, y: 0 }),
rotateToAngle: () => {},
panToCoordinates: () => {}
});
// 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 applicationRef = useRef<ApplicationRef>(null);
function screenToLocal(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
};
}
// Inverse of screenToLocal
function localToScreen(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
};
}
function getCenter() {
const canvas = applicationRef?.current?.getCanvas();
const canvasRect = canvas?.getBoundingClientRect();
const canvasLeft = canvasRect?.left ?? 0;
const canvasTop = canvasRect?.top ?? 0;
const centerX = window.innerWidth / 2 - canvasLeft;
const centerY = window.innerHeight / 2 - canvasTop;
return {x: centerX, y: centerY};
}
function rotateToAngle(to: number, fromPosition?: {x: number, y: number}) {
setRotation(to);
console.log(to, fromPosition);
const rotationDiff = to - rotation;
console.log("rotationDiff", rotationDiff);
const center = getCenter();
const cosDelta = Math.cos(rotationDiff);
const sinDelta = Math.sin(rotationDiff);
fromPosition ??= position;
setPosition({
x: center.x * (1 - cosDelta) + fromPosition.x * cosDelta + (center.y - fromPosition.y) * sinDelta,
y: center.y * (1 - cosDelta) + fromPosition.y * cosDelta + (fromPosition.x - center.x) * sinDelta
});
}
function panToCoordinates(latitude: number, longitude: number, rotation?: number) {
const center = getCenter();
const newPosition = {
x: -latitude * UP_SCALE,
y: -longitude * UP_SCALE
};
rotation ??= 0;
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
// Translate point relative to center, rotate, then translate back
const dx = newPosition.x;
const dy = newPosition.y;
newPosition.x = (dx * cos - dy * sin) + center.x;
newPosition.y = (dx * sin + dy * cos) + center.y;
setPosition(newPosition);
}
const value = useMemo(() => ({
position,
scale,
rotation,
applicationRef,
getCenter,
setPosition,
setScale,
setRotation,
rotateToAngle,
panToCoordinates,
screenToLocal,
localToScreen
}), [position, scale, rotation, applicationRef]);
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;
};

View File

@ -0,0 +1,36 @@
import { Graphics } from "pixi.js";
import { useCallback } from "react";
import { PATH_COLOR, PATH_WIDTH } from "./Constants";
interface TravelPathProps {
points: {x: number, y: number}[];
}
export function TravelPath({
points
}: Readonly<TravelPathProps>) {
const draw = useCallback((g: Graphics) => {
g.clear();
g.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length - 1; i++) {
g.lineTo(points[i].x, points[i].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}
/>
);
}

View File

@ -0,0 +1,31 @@
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">Станция</Typography>
</Stack>
<Stack bgcolor="primary.main"
width={223} height={262}
p={2} m={2}
borderRadius={2}
alignItems="center"
justifyContent="center"
>
<Typography variant="h6">Погода</Typography>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,137 @@
import { useRef, useEffect, useState } from "react";
import {
Application,
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";
extend({
Container,
Graphics,
Sprite,
Texture,
TilingSprite,
Text
});
export const RoutePreview = () => {
return (
<MapDataProvider>
<TransformProvider>
<Stack direction="row" height="100vh" width="100vw" overflow="hidden">
<LeftSidebar />
<Stack direction="row" flex={1} position="relative" height="100%">
<Widgets />
<RouteMap />
<RightSidebar />
</Stack>
</Stack>
</TransformProvider>
</MapDataProvider>
);
};
export function RouteMap() {
const { applicationRef, setPosition, screenToLocal, panToCoordinates } = useTransform();
const {
routeData, stationData, sightData, originalRouteData
} = useMapData();
const [points, setPoints] = useState<{x: number, y: number}[]>([]);
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 (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);
}
} else if (
originalRouteData?.center_latitude &&
originalRouteData?.center_longitude
) {
panToCoordinates(
originalRouteData?.center_latitude,
originalRouteData?.center_longitude,
originalRouteData?.rotate * Math.PI / 180
);
}
}, [points, originalRouteData?.center_latitude, originalRouteData?.center_longitude, originalRouteData?.rotate]);
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
ref={applicationRef}
resizeTo={parentRef}
background="#fff"
>
<InfiniteCanvas>
<TravelPath points={points}/>
{stationData?.map((obj) => (
<Station station={obj} key={obj.id}/>
))}
{sightData?.map((obj, index) => (
<Sight sight={obj} id={index} key={obj.id}/>
))}
<pixiGraphics
draw={(g) => {
g.clear();
const localCenter = screenToLocal(0,0);
g.circle(localCenter.x, localCenter.y, 10);
g.fill("#fff");
}}
/>
</InfiniteCanvas>
</Application>
</div>
)
}

View File

@ -0,0 +1,63 @@
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 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
}

View File

@ -1,4 +1,4 @@
import { Stack, Typography, Box } from "@mui/material";
import { Stack, Typography, Box, Button } from "@mui/material";
import { useShow } from "@refinedev/core";
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
import { LinkedItems } from "../../components/LinkedItems";
@ -8,11 +8,14 @@ import {
stationFields,
vehicleFields,
} from "./types";
import { useNavigate, useParams } from "react-router";
export const RouteShow = () => {
const { query } = useShow({});
const { data, isLoading } = query;
const record = data?.data;
const { id } = useParams();
const navigate = useNavigate();
const fields = [
{ label: "Перевозчик", data: "carrier" },
@ -87,6 +90,17 @@ export const RouteShow = () => {
/>
</>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
<Button
variant="contained"
color="primary"
onClick={() => navigate(`/route-preview/${id}`)}
>
Предпросмотр маршрута
</Button>
</Box>
</Stack>
</Show>
);