Compare commits
51 Commits
main
...
d67df0c2e1
| Author | SHA1 | Date | |
|---|---|---|---|
| d67df0c2e1 | |||
| b42802aac0 | |||
| 938a7e6d1e | |||
| beb9e932ef | |||
| a3a4d2eb18 | |||
| d380b2570f | |||
| fbf8232ce3 | |||
| 8d1de769c5 | |||
| 4b02c6e9d3 | |||
| a58f438dce | |||
| 5e0b56c7dc | |||
| a182a52111 | |||
| dd5aee58e6 | |||
| 442160ba38 | |||
| b6a9cecba6 | |||
| 591ca8104d | |||
| c3127b8d47 | |||
| 73070fe233 | |||
| 7cf188a55c | |||
| 2a9449ba58 | |||
| 1c097a4ca2 | |||
| 048848faa0 | |||
| 8fe6505249 | |||
| 58abe15ec4 | |||
| 144e7cb00c | |||
| d557664b25 | |||
| bbab6fc46a | |||
| 25155a66bc | |||
| a3d574a79c | |||
| 39e11ad5ca | |||
| 7e068e49f5 | |||
| 79539d0583 | |||
| c5c5f835bc | |||
| 5481d264e0 | |||
| d6772b1e3a | |||
| 11133b6839 | |||
| aaeaed3fa5 | |||
| 95fe297aae | |||
| 04a9ac452e | |||
| 85c71563c1 | |||
| 6f32c6e671 | |||
| 0a6192c7da | |||
| b1ba3b4cd5 | |||
| 1917b2cf5a | |||
| 5298fb9f60 | |||
| c95a6517e9 | |||
| 79f523e9cb | |||
| 90f3d66b22 | |||
| 2b48ade2f1 | |||
| b0fdf03cc6 | |||
|
|
349c7009c6 |
14
.env
14
.env
@@ -1,8 +1,8 @@
|
|||||||
VITE_API_URL='https://wn.st.unprism.ru'
|
# VITE_API_URL='https://wn.st.unprism.ru'
|
||||||
VITE_REACT_APP ='https://wn.st.unprism.ru/'
|
# VITE_REACT_APP ='https://wn.st.unprism.ru/'
|
||||||
VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
|
# VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
|
||||||
VITE_NEED_AUTH='true'
|
|
||||||
# VITE_API_URL='https://wn.krbl.ru'
|
|
||||||
# VITE_REACT_APP ='https://wn.krbl.ru/'
|
|
||||||
# VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
|
||||||
# VITE_NEED_AUTH='true'
|
# VITE_NEED_AUTH='true'
|
||||||
|
VITE_API_URL='https://wn.krbl.ru'
|
||||||
|
VITE_REACT_APP ='https://wn.krbl.ru/'
|
||||||
|
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
||||||
|
VITE_NEED_AUTH='true'
|
||||||
|
|||||||
26
.gitignore
vendored
26
.gitignore
vendored
@@ -1,7 +1,14 @@
|
|||||||
# Logs
|
# Logs
|
||||||
|
<<<<<<< HEAD
|
||||||
|
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
npm-debug.log_
|
||||||
|
=======
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
>>>>>>> 6b9aa78 (init: Init React Application)
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
@@ -10,10 +17,22 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
<<<<<<< HEAD
|
||||||
|
\*.local
|
||||||
|
|
||||||
# Build artifacts
|
# Editor directories and files
|
||||||
*.tsbuildinfo
|
|
||||||
|
.vscode/_
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
_.suo
|
||||||
|
_.ntvs_
|
||||||
|
_.njsproj
|
||||||
|
_.sln
|
||||||
|
\*.sw?
|
||||||
|
=======
|
||||||
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
@@ -25,3 +44,4 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
>>>>>>> 6b9aa78 (init: Init React Application)
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
<svg width="24" height="21" viewBox="0 0 24 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M11.3888 2.85031C11.7638 2.66434 12.1989 2.66434 12.5739 2.85031L21.0037 7.06792C21.4462 7.29108 21.7245 7.73709 21.7245 8.22802V20.145H21.7464C21.7462 20.6134 21.3641 20.9999 20.8843 21H15.7689C15.3788 21 15.0562 20.6874 15.0562 20.2932V13.807C15.0562 12.1333 13.6909 10.7792 12.0033 10.7792C10.3159 10.7794 8.95116 12.1334 8.95115 13.807V20.2932C8.95115 20.68 8.63599 21 8.23847 21H3.10101C2.62861 21 2.23837 20.6209 2.23818 20.145V8.22802C2.23819 7.73714 2.51576 7.2911 2.95818 7.06792L11.3888 2.85031Z" fill="white"/>
|
|
||||||
<path d="M10.7962 0.268599C11.5012 -0.088374 12.3413 -0.0882526 13.0463 0.261335L23.6142 5.54243C23.9592 5.71352 24.1018 6.1379 23.9218 6.48751C23.7943 6.73297 23.5464 6.87397 23.2839 6.87397C23.179 6.87392 23.0663 6.85189 22.9689 6.79987L12.401 1.51877C12.101 1.37 11.7408 1.37 11.4408 1.51877L1.03036 6.79261C0.677932 6.97088 0.250655 6.82952 0.0781632 6.48751C-0.101845 6.14534 0.0407786 5.72096 0.385795 5.54243L10.7962 0.268599Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "white-nights",
|
"name": "white-nights",
|
||||||
"version": "1.0.8",
|
"version": "1.0.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "white-nights",
|
"name": "white-nights",
|
||||||
"version": "1.0.8",
|
"version": "1.0.6",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "white-nights",
|
"name": "white-nights",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.8",
|
"version": "1.0.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"@mui/material": "^7.1.0",
|
"@mui/material": "^7.1.0",
|
||||||
"@mui/x-data-grid": "^8.5.1",
|
"@mui/x-data-grid": "^8.5.1",
|
||||||
"@photo-sphere-viewer/core": "^5.13.2",
|
"@photo-sphere-viewer/core": "^5.13.2",
|
||||||
|
"@pixi/react": "^8.0.2",
|
||||||
"@react-three/drei": "^10.1.2",
|
"@react-three/drei": "^10.1.2",
|
||||||
"@react-three/fiber": "^9.1.2",
|
"@react-three/fiber": "^9.1.2",
|
||||||
"@tailwindcss/vite": "^4.1.8",
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"overlayscrollbars": "^2.15.1",
|
"overlayscrollbars": "^2.15.1",
|
||||||
"overlayscrollbars-react": "^0.5.6",
|
"overlayscrollbars-react": "^0.5.6",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
|
"pixi.js": "^8.10.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { Router } from "./router";
|
import { Router } from "./router";
|
||||||
import { CustomTheme, languageStore } from "@shared";
|
import { CustomTheme } from "@shared";
|
||||||
import { ThemeProvider } from "@mui/material/styles";
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
|
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
|
||||||
import { TestingModeBanner } from "@widgets";
|
import { TestingModeBanner } from "@widgets";
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
|
|
||||||
export const App: React.FC = observer(() => {
|
export const App: React.FC = () => (
|
||||||
React.useEffect(() => {
|
<GlobalErrorBoundary>
|
||||||
document.documentElement.lang = languageStore.language;
|
<ThemeProvider theme={CustomTheme.Light}>
|
||||||
}, [languageStore.language]);
|
<TestingModeBanner />
|
||||||
|
<ToastContainer />
|
||||||
return (
|
<Router />
|
||||||
<GlobalErrorBoundary>
|
</ThemeProvider>
|
||||||
<ThemeProvider theme={CustomTheme.Light}>
|
</GlobalErrorBoundary>
|
||||||
<TestingModeBanner />
|
);
|
||||||
<ToastContainer />
|
|
||||||
<Router />
|
|
||||||
</ThemeProvider>
|
|
||||||
</GlobalErrorBoundary>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -92,9 +92,6 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
|||||||
requiredPermissions.length > 0 &&
|
requiredPermissions.length > 0 &&
|
||||||
!requiredPermissions.every((permission) => authStore.canAccess(permission))
|
!requiredPermissions.every((permission) => authStore.canAccess(permission))
|
||||||
) {
|
) {
|
||||||
if (location.pathname === "/devices" && authStore.hasRole("devices_maintenance_rw")) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
4
src/client/src/App.d.ts
vendored
4
src/client/src/App.d.ts
vendored
@@ -1,4 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
declare const App: React.FC;
|
|
||||||
export default App;
|
|
||||||
@@ -23,7 +23,6 @@ const App = observer(() => {
|
|||||||
loadingStatus,
|
loadingStatus,
|
||||||
loadingProgress,
|
loadingProgress,
|
||||||
context,
|
context,
|
||||||
carrier,
|
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
setLoadingStatus,
|
setLoadingStatus,
|
||||||
setLoadingProgress,
|
setLoadingProgress,
|
||||||
@@ -173,7 +172,6 @@ const App = observer(() => {
|
|||||||
loadingStatus={loadingStatus}
|
loadingStatus={loadingStatus}
|
||||||
loadingProgress={loadingProgress}
|
loadingProgress={loadingProgress}
|
||||||
onToggleDebug={() => setIsDebugVisible((prev) => !prev)}
|
onToggleDebug={() => setIsDebugVisible((prev) => !prev)}
|
||||||
videoId={carrier?.video_preview}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -21,57 +21,7 @@ import {
|
|||||||
GetCityResponse,
|
GetCityResponse,
|
||||||
GetSightArticleResponse,
|
GetSightArticleResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
// @ts-ignore
|
|
||||||
import { orderStationsByRoute } from "../../utils/routeStationsUtils";
|
import { orderStationsByRoute } from "../../utils/routeStationsUtils";
|
||||||
import { resamplePath } from "../../utils/animationUtils";
|
|
||||||
import { colorStore } from "../../stores/ColorStore";
|
|
||||||
|
|
||||||
function hexToRgbString(hex: string): string | null {
|
|
||||||
const clean = hex.trim().replace(/^#/, "");
|
|
||||||
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
|
|
||||||
if (full.length !== 6) return null;
|
|
||||||
const r = parseInt(full.slice(0, 2), 16);
|
|
||||||
const g = parseInt(full.slice(2, 4), 16);
|
|
||||||
const b = parseInt(full.slice(4, 6), 16);
|
|
||||||
return `${r}, ${g}, ${b}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function darkenHex(hex: string, amount: number): string {
|
|
||||||
const rgb = hexToRgbString(hex);
|
|
||||||
if (!rgb) return hex;
|
|
||||||
const [r, g, b] = rgb.split(",").map(Number);
|
|
||||||
const factor = 1 - amount;
|
|
||||||
const dr = Math.round(r * factor);
|
|
||||||
const dg = Math.round(g * factor);
|
|
||||||
const db = Math.round(b * factor);
|
|
||||||
return `#${dr.toString(16).padStart(2, "0")}${dg.toString(16).padStart(2, "0")}${db.toString(16).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function lightenRgbString(hex: string, amount: number): string | null {
|
|
||||||
const rgb = hexToRgbString(hex);
|
|
||||||
if (!rgb) return null;
|
|
||||||
const [r, g, b] = rgb.split(",").map(Number);
|
|
||||||
const lr = Math.round(r + (255 - r) * amount);
|
|
||||||
const lg = Math.round(g + (255 - g) * amount);
|
|
||||||
const lb = Math.round(b + (255 - b) * amount);
|
|
||||||
return `${lr}, ${lg}, ${lb}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyCarrierColors(carrier: { main_color?: string; left_color?: string; right_color?: string }) {
|
|
||||||
const mainColor = carrier.main_color || "#006f3a";
|
|
||||||
const leftColor = carrier.left_color || "#006f3a";
|
|
||||||
const rightColor = carrier.right_color || "#006f3a";
|
|
||||||
const mainDark = darkenHex(mainColor, 0.3);
|
|
||||||
|
|
||||||
document.documentElement.style.setProperty("--carrier-main", mainColor);
|
|
||||||
document.documentElement.style.setProperty("--carrier-main-rgb", hexToRgbString(mainColor) ?? "0, 111, 58");
|
|
||||||
document.documentElement.style.setProperty("--carrier-main-dark", mainDark);
|
|
||||||
document.documentElement.style.setProperty("--carrier-left", leftColor);
|
|
||||||
document.documentElement.style.setProperty("--carrier-left-rgb", hexToRgbString(leftColor) ?? "0, 111, 58");
|
|
||||||
document.documentElement.style.setProperty("--carrier-right", rightColor);
|
|
||||||
document.documentElement.style.setProperty("--carrier-right-rgb", hexToRgbString(rightColor) ?? "0, 111, 58");
|
|
||||||
document.documentElement.style.setProperty("--carrier-right-menu-rgb", lightenRgbString(rightColor, 0.38) ?? "179, 165, 152");
|
|
||||||
}
|
|
||||||
|
|
||||||
class ApiStore {
|
class ApiStore {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -104,14 +54,13 @@ class ApiStore {
|
|||||||
carrier: GetCarrierResponse | null = null;
|
carrier: GetCarrierResponse | null = null;
|
||||||
city: GetCityResponse | null = null;
|
city: GetCityResponse | null = null;
|
||||||
|
|
||||||
positionIndex = 0;
|
private positionIndex = 0;
|
||||||
private positionInterval: ReturnType<typeof setInterval> | null = null;
|
private positionInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
simulationSpeed = 1;
|
simulationSpeed = 1;
|
||||||
simulationDirection: 1 | -1 = 1;
|
simulationDirection: 1 | -1 = 1;
|
||||||
simulationPaused = false;
|
simulationPaused = false;
|
||||||
simulationInstantMove = false;
|
simulationInstantMove = false;
|
||||||
showHitboxes = false;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
@@ -138,35 +87,12 @@ class ApiStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
getRoute = async () => {
|
getRoute = async () => {
|
||||||
const route = await getRoute(this.routeId!);
|
this.route = await getRoute(this.routeId!);
|
||||||
if (route.path && route.path.length > 1) {
|
|
||||||
// Рассчитываем общую дистанцию для выбора адекватного шага ресемплинга
|
|
||||||
let totalDist = 0;
|
|
||||||
for (let i = 0; i < route.path.length - 1; i++) {
|
|
||||||
const p1 = route.path[i];
|
|
||||||
const p2 = route.path[i + 1];
|
|
||||||
totalDist += Math.sqrt(
|
|
||||||
Math.pow(p2[0] - p1[0], 2) + Math.pow(p2[1] - p1[1], 2)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Хотим иметь примерно 2000 точек для равномерности и плавности
|
|
||||||
const segmentLength = totalDist / 2000;
|
|
||||||
if (segmentLength > 0) {
|
|
||||||
route.path = resamplePath(route.path as [number, number][], segmentLength);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
runInAction(() => {
|
|
||||||
this.route = route;
|
|
||||||
});
|
|
||||||
this.updateOrderedRouteStations();
|
this.updateOrderedRouteStations();
|
||||||
};
|
};
|
||||||
|
|
||||||
getCarrier = async () => {
|
getCarrier = async () => {
|
||||||
this.carrier = await getCarrier(this.route!.carrier_id!);
|
this.carrier = await getCarrier(this.route!.carrier_id!);
|
||||||
applyCarrierColors(this.carrier);
|
|
||||||
if (this.carrier.main_color) {
|
|
||||||
colorStore.setMainColor(this.carrier.main_color);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
getCity = async () => {
|
getCity = async () => {
|
||||||
@@ -244,10 +170,6 @@ class ApiStore {
|
|||||||
this.simulationInstantMove = !this.simulationInstantMove;
|
this.simulationInstantMove = !this.simulationInstantMove;
|
||||||
};
|
};
|
||||||
|
|
||||||
toggleShowHitboxes = () => {
|
|
||||||
this.showHitboxes = !this.showHitboxes;
|
|
||||||
};
|
|
||||||
|
|
||||||
startPositionSimulation = () => {
|
startPositionSimulation = () => {
|
||||||
if (this.positionInterval) return;
|
if (this.positionInterval) return;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export type GetContextResponse = {
|
|||||||
};
|
};
|
||||||
endStopId: string;
|
endStopId: string;
|
||||||
nearestSightId: string;
|
nearestSightId: string;
|
||||||
nearestStationId?: string | null;
|
|
||||||
rawCoordinates: {
|
rawCoordinates: {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
@@ -55,7 +54,6 @@ export type GetRouteResponse = {
|
|||||||
center_latitude: number;
|
center_latitude: number;
|
||||||
center_longitude: number;
|
center_longitude: number;
|
||||||
governor_appeal: number;
|
governor_appeal: number;
|
||||||
button_text?: string;
|
|
||||||
id: number;
|
id: number;
|
||||||
path: [number, number][];
|
path: [number, number][];
|
||||||
rotate: number;
|
rotate: number;
|
||||||
@@ -77,13 +75,8 @@ export type GetCarrierResponse = {
|
|||||||
full_name: string;
|
full_name: string;
|
||||||
id: number;
|
id: number;
|
||||||
logo: string;
|
logo: string;
|
||||||
preview_media?: string;
|
|
||||||
video_preview?: string;
|
|
||||||
short_name: string;
|
short_name: string;
|
||||||
slogan: string;
|
slogan: string;
|
||||||
main_color?: string;
|
|
||||||
left_color?: string;
|
|
||||||
right_color?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetCityResponse = {
|
export type GetCityResponse = {
|
||||||
@@ -112,7 +105,6 @@ export type GetRouteSightsResponse = {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
alt_icon?: string;
|
alt_icon?: string;
|
||||||
is_default_icon?: boolean;
|
is_default_icon?: boolean;
|
||||||
short_name?: string;
|
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
export type GetRouteStationsResponse = {
|
export type GetRouteStationsResponse = {
|
||||||
|
|||||||
9
src/client/src/api/apiConfig.d.ts
vendored
9
src/client/src/api/apiConfig.d.ts
vendored
@@ -1,9 +0,0 @@
|
|||||||
import { AxiosInstance } from "axios";
|
|
||||||
|
|
||||||
export declare const apiBaseURL: string;
|
|
||||||
export declare const geoBaseURL: string;
|
|
||||||
export declare const weatherBaseURL: string;
|
|
||||||
export declare const getMediaUrl: (id: string) => string;
|
|
||||||
export declare const apiInstance: AxiosInstance;
|
|
||||||
export declare const geoInstance: AxiosInstance;
|
|
||||||
export declare const weatherInstance: AxiosInstance;
|
|
||||||
8
src/client/src/assets/Constants.d.ts
vendored
8
src/client/src/assets/Constants.d.ts
vendored
@@ -1,8 +0,0 @@
|
|||||||
export declare const UP_SCALE: number;
|
|
||||||
export declare const PATH_WIDTH: number;
|
|
||||||
export declare const STATION_RADIUS: number;
|
|
||||||
export declare const STATION_OUTLINE_WIDTH: number;
|
|
||||||
export declare const SIGHT_SIZE: number;
|
|
||||||
export declare const SCALE_FACTOR: number;
|
|
||||||
export declare const BACKGROUND_COLOR: number;
|
|
||||||
export declare const PATH_COLOR: number;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<svg width="24" height="21" viewBox="0 0 24 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M11.3888 2.85031C11.7638 2.66434 12.1989 2.66434 12.5739 2.85031L21.0037 7.06792C21.4462 7.29108 21.7245 7.73709 21.7245 8.22802V20.145H21.7464C21.7462 20.6134 21.3641 20.9999 20.8843 21H15.7689C15.3788 21 15.0562 20.6874 15.0562 20.2932V13.807C15.0562 12.1333 13.6909 10.7792 12.0033 10.7792C10.3159 10.7794 8.95116 12.1334 8.95115 13.807V20.2932C8.95115 20.68 8.63599 21 8.23847 21H3.10101C2.62861 21 2.23837 20.6209 2.23818 20.145V8.22802C2.23819 7.73714 2.51576 7.2911 2.95818 7.06792L11.3888 2.85031Z" fill="white"/>
|
|
||||||
<path d="M10.7962 0.268599C11.5012 -0.088374 12.3413 -0.0882526 13.0463 0.261335L23.6142 5.54243C23.9592 5.71352 24.1018 6.1379 23.9218 6.48751C23.7943 6.73297 23.5464 6.87397 23.2839 6.87397C23.179 6.87392 23.0663 6.85189 22.9689 6.79987L12.401 1.51877C12.101 1.37 11.7408 1.37 11.4408 1.51877L1.03036 6.79261C0.677932 6.97088 0.250655 6.82952 0.0781632 6.48751C-0.101845 6.14534 0.0407786 5.72096 0.385795 5.54243L10.7962 0.268599Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,3 +0,0 @@
|
|||||||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M13.9941 5.96193C13.028 4.86487 12.0189 3.81 11.0097 2.73404C10.5374 2.2277 9.78591 2.14332 9.18473 2.50197C8.86267 2.69185 8.64796 2.98721 8.51914 3.32476C7.51002 2.33319 6.47943 1.42601 5.47031 0.434434C4.69737 -0.325069 3.47355 -0.0508043 3.10855 0.961866C3.10855 1.00406 3.08707 1.00406 3.0656 1.02516C2.65766 0.750893 2.18531 0.708699 1.69148 0.919672C1.11178 1.17284 0.811185 1.61588 0.789714 2.2488C0.789714 2.56526 0.897071 2.86062 1.06884 3.13489C0.746777 3.24038 0.489128 3.40915 0.295893 3.66232C-0.154991 4.25305 -0.09058 5.05474 0.489127 5.60327C2.16384 7.24886 3.83854 8.89445 5.53472 10.5611C5.57767 10.6033 5.62061 10.6455 5.68502 10.7088C5.66355 10.7088 5.64208 10.7088 5.62061 10.7088C4.63296 10.8565 3.62384 11.0253 2.63619 11.173C1.88472 11.2784 1.3909 11.6793 1.17619 12.3966C0.918542 13.2405 1.43383 14 2.29266 14C4.71884 14 7.12355 14 9.54973 14C10.7091 14 11.6968 13.5992 12.5127 12.7975C12.9421 12.3755 13.3715 11.9536 13.8009 11.5316C15.3253 9.99151 15.4112 7.56532 13.9727 5.94083L13.9941 5.96193ZM13.2212 11.0675C12.8133 11.4683 12.4053 11.8692 11.9974 12.27C11.3103 12.924 10.4944 13.2616 9.52826 13.2616C8.36885 13.2616 7.18796 13.2616 6.02855 13.2616C4.86913 13.2616 3.60236 13.2616 2.37854 13.2616C1.99207 13.2616 1.82031 13.0506 1.92766 12.692C2.01354 12.2911 2.3356 12.0169 2.76501 11.9536C4.01031 11.7637 5.27708 11.5738 6.52237 11.3839C6.69414 11.3628 6.84443 11.2995 6.88737 11.1308C6.95178 10.9409 6.88737 10.7932 6.73708 10.6666C4.84767 8.81006 2.95825 6.9535 1.06884 5.09694C0.83266 4.86487 0.746777 4.6117 0.85413 4.31634C1.00442 3.87329 1.56266 3.72561 1.94913 3.99988C2.01354 4.04207 2.05648 4.10536 2.1209 4.14756C3.34472 5.35011 4.59001 6.57375 5.81384 7.77629C5.92119 7.88178 6.02855 7.96617 6.17884 7.94507C6.47943 7.94507 6.62972 7.60752 6.47943 7.37545C6.43649 7.31215 6.37208 7.24886 6.32914 7.20667C4.84767 5.72986 3.34472 4.27414 1.84177 2.81843C1.64854 2.62855 1.54119 2.39648 1.6056 2.12222C1.73443 1.61588 2.3356 1.42601 2.74354 1.76356C2.78648 1.80576 2.82943 1.84795 2.89384 1.89015C4.35384 3.32476 5.83532 4.78048 7.29532 6.2151C7.33826 6.25729 7.35973 6.29948 7.40267 6.32058C7.57444 6.46826 7.81061 6.48936 7.9609 6.32058C8.1112 6.1729 8.1112 5.94083 7.9609 5.77205C7.72473 5.53998 7.25237 5.07584 7.25237 5.07584L6.24325 4.08427C6.24325 4.08427 5.49178 3.34586 5.12678 2.98721C4.76178 2.62855 4.41825 2.291 4.05325 1.93234C3.88149 1.74247 3.77413 1.55259 3.83855 1.27833C3.9459 0.814185 4.50413 0.62431 4.89061 0.919672C4.95502 0.961866 4.99796 1.02516 5.0409 1.06735C6.1359 2.14332 7.25238 3.11379 8.34738 4.18975C8.54061 4.37963 8.71238 4.56951 8.90561 4.73828C9.09885 4.92816 9.33502 4.94926 9.50679 4.80158C9.67855 4.65389 9.65708 4.42182 9.46385 4.21085C9.18473 3.89439 9.2062 3.47245 9.50679 3.21928C9.80738 2.96611 10.2368 2.98721 10.5159 3.28257C11.4606 4.29524 12.4053 5.32901 13.35 6.34168C14.5953 7.713 14.5524 9.73834 13.2427 11.0464L13.2212 11.0675Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -1,3 +0,0 @@
|
|||||||
<svg width="15" height="12" viewBox="0 0 15 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M13.993 3.76793C13.0258 2.64439 12.0155 1.56407 11.0053 0.462135C10.5324 -0.0564207 9.78007 -0.142847 9.17822 0.224464C8.8558 0.418922 8.64085 0.721413 8.51188 1.06712C7.69509 0.505348 6.57737 0.786232 6.19046 1.82334C6.19046 1.82334 6.19046 1.82334 6.19046 1.88816C5.78206 1.62889 5.33068 1.60728 4.85779 1.82334C4.40641 2.0178 4.14847 2.3419 4.0195 2.75242C3.6326 2.36351 2.60085 1.348 2.47189 1.21836C2.0205 0.786232 1.46164 0.699806 0.902776 0.959084C0.773808 1.0023 0.687829 1.06712 0.580356 1.15354C0.365409 1.30479 0.236441 1.49925 0.128968 1.73692C0.128968 1.78013 0.107473 1.82334 0.0859786 1.86656C0 1.9962 0 2.14744 0 2.29869C0 2.53636 0.064484 2.75242 0.150463 2.96849C0.150463 2.96849 0.343915 3.27098 0.472883 3.40062C2.14947 5.08592 3.82605 6.77123 5.52413 8.47814C5.56712 8.52135 5.61011 8.56457 5.67459 8.62939C5.6531 8.62939 5.6316 8.62939 5.61011 8.62939C4.62135 8.78063 3.6111 8.95348 2.62235 9.10473C1.87004 9.21276 1.37566 9.62329 1.16071 10.3579C0.902776 11.2222 1.41865 12 2.27843 12C4.70733 12 7.11473 12 9.54363 12C10.7043 12 11.6931 11.5895 12.5099 10.7684C12.9398 10.3363 13.3697 9.90417 13.7996 9.47204C15.3257 7.89477 15.4117 5.41002 13.9715 3.74632L13.993 3.76793ZM13.2192 8.9967C12.8108 9.40722 12.4024 9.81774 11.994 10.2283C11.3062 10.8981 10.4894 11.2438 9.52213 11.2438C8.36142 11.2438 7.17922 11.2438 6.0185 11.2438C4.85779 11.2438 3.58961 11.2438 2.36441 11.2438C1.97751 11.2438 1.80555 11.0277 1.91302 10.6604C1.999 10.2499 2.32142 9.96899 2.75132 9.90417C3.99801 9.70971 5.26619 9.51525 6.51288 9.3208C6.68484 9.29919 6.8353 9.23437 6.87829 9.06152C6.94278 8.86706 6.87829 8.71581 6.72783 8.58617C4.8363 6.6848 2.94477 4.78343 1.05324 2.88206C0.92427 2.75242 0.838292 2.60118 0.795302 2.44993C0.795302 2.44993 0.795302 2.40672 0.795302 2.38511C0.795302 2.38511 0.795302 2.3419 0.795302 2.32029C0.795302 2.27708 0.795302 2.23387 0.795302 2.16905C0.795302 2.16905 0.795302 2.14744 0.795302 2.12583C0.795302 2.10423 0.795302 2.08262 0.816797 2.06101C0.816797 2.03941 0.838292 1.9962 0.859786 1.97459C1.07473 1.60728 1.56911 1.49925 1.93452 1.80174C1.97751 1.84495 2.0205 1.88816 2.08498 1.93138C3.05224 2.90367 4.0195 3.87596 4.98676 4.84825C5.2447 5.10753 5.52413 5.38841 5.78206 5.64769C5.88954 5.75572 5.99701 5.84215 6.14747 5.82054C6.4484 5.82054 6.59886 5.47484 6.4484 5.23717C6.40541 5.17235 6.34092 5.10753 6.29794 5.06432C5.84655 4.61058 5.37366 4.13524 4.92228 3.6815C4.77181 3.50865 4.70733 3.29258 4.77181 3.05491C4.90078 2.53636 5.50263 2.3419 5.91103 2.6876C5.95402 2.73082 5.99701 2.77403 6.06149 2.81724C6.38391 3.14134 6.68484 3.44383 7.00726 3.76793C7.09324 3.85435 7.17922 3.94078 7.28669 4.04881C7.32968 4.09202 7.35117 4.13524 7.39416 4.15684C7.56612 4.30809 7.80256 4.3297 7.95302 4.15684C8.10349 4.0056 8.10349 3.76793 7.95302 3.59508C7.80256 3.44383 7.6521 3.27098 7.48014 3.11973C7.35117 2.99009 7.2437 2.88206 7.11473 2.75242C6.8568 2.42833 6.92128 2.0178 7.15772 1.78013C7.45865 1.47764 7.86705 1.49925 8.23245 1.86656C8.4689 2.10423 8.68384 2.32029 8.92028 2.55796C9.11374 2.75242 9.35018 2.77403 9.52213 2.62278C9.69409 2.47154 9.6726 2.23387 9.47915 2.0178C9.19971 1.6937 9.22121 1.26158 9.52213 1.0023C9.82306 0.743019 10.253 0.764626 10.5324 1.06712C11.4781 2.10423 12.4239 3.16295 13.3697 4.20006C14.6164 5.60448 14.5734 7.6787 13.2622 9.0183L13.2192 8.9967Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -1,3 +0,0 @@
|
|||||||
<svg width="15" height="13" viewBox="0 0 15 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M5.67979 9.7487C5.63689 9.68658 5.57254 9.64516 5.52964 9.60374C3.85647 7.98845 2.16185 6.37316 0.488679 4.73716C-0.0904948 4.17802 -0.154849 3.41179 0.295619 2.83194C0.488677 2.58343 0.746089 2.41776 1.06785 2.31422C0.896245 2.045 0.767539 1.77579 0.78899 1.44444C0.78899 0.823178 1.11075 0.388292 1.68993 0.139785C2.2691 -0.108722 2.80537 -0.0258861 3.25584 0.388291C3.87792 0.96814 4.47854 1.5687 5.07917 2.14855C5.44383 2.5006 5.8085 2.85265 6.19461 3.22541C6.58073 2.23138 7.69618 1.94146 8.51131 2.5006C8.64001 2.14855 8.85452 1.87933 9.17629 1.69295C9.77691 1.3409 10.5277 1.42374 10.9996 1.92075C12.0078 2.9769 13.016 4.01235 13.9813 5.08921C15.4185 6.68379 15.3112 9.06531 13.8097 10.5771C13.3807 10.9912 12.9516 11.4054 12.5226 11.8196C11.7075 12.5858 10.7207 12.9793 9.5624 13C7.13845 13 4.73595 13 2.312 13C1.45397 13 0.917694 12.2752 1.19656 11.4261C1.41106 10.722 1.92589 10.3493 2.65522 10.225C3.64196 10.08 4.65015 9.91438 5.63689 9.76941C5.63689 9.76941 5.65834 9.76941 5.70124 9.76941L5.67979 9.7487ZM6.023 12.2545C7.18135 12.2545 8.36115 12.2545 9.5195 12.2545C10.4848 12.2545 11.2999 11.9231 11.9864 11.2812C12.3939 10.8877 12.8015 10.4942 13.2091 10.1008C14.5176 8.81681 14.5819 6.82875 13.3163 5.48268C12.3725 4.46794 11.4286 3.47391 10.4848 2.47989C10.2059 2.18996 9.77691 2.14855 9.4766 2.41776C9.17629 2.68698 9.15484 3.10115 9.4337 3.39108C9.62675 3.59817 9.62675 3.80526 9.4766 3.97093C9.30499 4.11589 9.09048 4.09518 8.87597 3.9088C8.64001 3.70171 8.4255 3.47391 8.18955 3.24612C7.84633 2.91477 7.41731 2.87336 7.117 3.16328C6.85959 3.41179 6.79524 3.86738 7.18135 4.19873C7.43877 4.40581 7.65327 4.65432 7.88923 4.88212C8.06084 5.04779 8.06084 5.27559 7.88923 5.42055C7.73908 5.56551 7.50312 5.56551 7.33151 5.42055C7.28861 5.37913 7.24571 5.33771 7.22426 5.317C5.7656 3.9088 4.28548 2.47989 2.82682 1.07168C2.78392 1.03027 2.74102 0.988849 2.67667 0.947431C2.2691 0.616089 1.66848 0.802469 1.53977 1.29948C1.47542 1.5687 1.58267 1.7965 1.77573 1.98288C3.27729 3.41179 4.7574 4.86141 6.25897 6.29032C6.32332 6.35245 6.36622 6.39387 6.40912 6.45599C6.55928 6.7045 6.40912 6.99442 6.10881 7.01513C5.95865 7.01513 5.8514 6.9323 5.74415 6.84946C4.52145 5.66906 3.27729 4.46794 2.05459 3.28753C1.99024 3.22541 1.94734 3.18399 1.88299 3.14257C1.49687 2.87336 0.960597 3.01832 0.78899 3.45321C0.681736 3.76384 0.78899 4.01235 1.0035 4.21943C2.89118 6.04182 4.77885 7.8642 6.66653 9.68658C6.79524 9.81083 6.88104 9.95579 6.81669 10.1422C6.75234 10.3078 6.62363 10.37 6.45202 10.3907C5.20787 10.5771 3.94227 10.7634 2.69812 10.9498C2.2691 11.0119 1.96879 11.2812 1.86153 11.6746C1.77573 12.0474 1.92589 12.2338 2.312 12.2338C3.5347 12.2338 4.73595 12.2338 5.95865 12.2338L6.023 12.2545Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -37,14 +37,14 @@ const Fullscreen3DModal = ({ isOpen, onClose, fileUrl }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="fullscreen-3d-actions">
|
<div className="fullscreen-3d-actions">
|
||||||
<button disabled={scale >= 1}>
|
<button title="Увеличить масштаб" disabled={scale >= 1}>
|
||||||
<img src={scale_plus} alt="Увеличить" />
|
<img src={scale_plus} alt="Увеличить" />
|
||||||
</button>
|
</button>
|
||||||
<button disabled={scale <= 0.1}>
|
<button title="Уменьшить масштаб" disabled={scale <= 0.1}>
|
||||||
<img src={scale_minus} alt="Уменьшить" />
|
<img src={scale_minus} alt="Уменьшить" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button onPointerUp={onClose}>
|
<button onPointerUp={onClose} title="Закрыть">
|
||||||
<img src={closeIcon} alt="Закрыть" />
|
<img src={closeIcon} alt="Закрыть" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,10 +95,9 @@ const ListOfSights = observer(() => {
|
|||||||
|
|
||||||
// Находим все достопримечательности, начинающиеся с выбранной буквы
|
// Находим все достопримечательности, начинающиеся с выбранной буквы
|
||||||
const sightsForLetter = sightData.filter((sight) => {
|
const sightsForLetter = sightData.filter((sight) => {
|
||||||
const name = sight.name;
|
if (!sight.name) return false;
|
||||||
if (!name) return false;
|
|
||||||
|
|
||||||
let sightFirstChar = name.trim().charAt(0);
|
let sightFirstChar = sight.name.trim().charAt(0);
|
||||||
|
|
||||||
// Для китайского языка используем пиньинь или иероглиф
|
// Для китайского языка используем пиньинь или иероглиф
|
||||||
if (selectedLanguageRight === "zh") {
|
if (selectedLanguageRight === "zh") {
|
||||||
@@ -106,7 +105,7 @@ const ListOfSights = observer(() => {
|
|||||||
sightFirstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
|
sightFirstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
|
||||||
} else {
|
} else {
|
||||||
// Если пиньинь нет, используем первый иероглиф
|
// Если пиньинь нет, используем первый иероглиф
|
||||||
sightFirstChar = name.trim().charAt(0);
|
sightFirstChar = sight.name.trim().charAt(0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sightFirstChar = sightFirstChar.toUpperCase();
|
sightFirstChar = sightFirstChar.toUpperCase();
|
||||||
@@ -412,13 +411,13 @@ const ListOfSights = observer(() => {
|
|||||||
}, [currentSelectedSight]);
|
}, [currentSelectedSight]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="right-widget" lang={selectedLanguageRight}>
|
<div className="right-widget">
|
||||||
{currentSelectedSight && (
|
{currentSelectedSight && (
|
||||||
<SightFrame
|
<SightFrame
|
||||||
key={currentSelectedSight.id}
|
key={currentSelectedSight.id}
|
||||||
media={sightFrameMedia}
|
media={sightFrameMedia}
|
||||||
sight_id={currentSelectedSight.id}
|
sight_id={currentSelectedSight.id}
|
||||||
sight_name={currentSelectedSight.name}
|
sight_name={currentSelectedSight.short_name || currentSelectedSight.name}
|
||||||
selectedLanguageRight={selectedLanguageRight}
|
selectedLanguageRight={selectedLanguageRight}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -441,6 +440,11 @@ const ListOfSights = observer(() => {
|
|||||||
isLangOpen={isLangMenuOpen}
|
isLangOpen={isLangMenuOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TransferWidget
|
||||||
|
isOpen={isTransferWidgetOpen}
|
||||||
|
selectedLanguageRight={selectedLanguage}
|
||||||
|
/>
|
||||||
|
|
||||||
<LanguageSelector
|
<LanguageSelector
|
||||||
selectedLanguageRight={selectedLanguageRight}
|
selectedLanguageRight={selectedLanguageRight}
|
||||||
onLanguageChange={handleLanguageChange}
|
onLanguageChange={handleLanguageChange}
|
||||||
@@ -472,19 +476,13 @@ const ListOfSights = observer(() => {
|
|||||||
isDisabled={isAlphabetDisabled}
|
isDisabled={isAlphabetDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<TransferWidget
|
<div
|
||||||
isOpen={isTransferWidgetOpen}
|
className="transfer-button-container"
|
||||||
selectedLanguageRight={selectedLanguage}
|
style={{
|
||||||
/>
|
transition: "transform 0.3s ease",
|
||||||
|
}}
|
||||||
<div
|
>
|
||||||
className="transfer-button-container"
|
|
||||||
style={{
|
|
||||||
transition: "transform 0.3s ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
@@ -514,6 +512,7 @@ const ListOfSights = observer(() => {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,9 +14,8 @@ const AlphabetNavigator = forwardRef(function AlphabetNavigator(
|
|||||||
const letters = new Set();
|
const letters = new Set();
|
||||||
|
|
||||||
sightData.forEach((sight) => {
|
sightData.forEach((sight) => {
|
||||||
const name = sight.short_name || sight.name;
|
if (sight.name && sight.name.trim()) {
|
||||||
if (name && name.trim()) {
|
let firstChar = sight.name.trim().charAt(0);
|
||||||
let firstChar = name.trim().charAt(0);
|
|
||||||
|
|
||||||
// Для китайского языка используем пиньинь (если доступен) или иероглиф
|
// Для китайского языка используем пиньинь (если доступен) или иероглиф
|
||||||
if (selectedLanguage === "zh") {
|
if (selectedLanguage === "zh") {
|
||||||
@@ -24,7 +23,7 @@ const AlphabetNavigator = forwardRef(function AlphabetNavigator(
|
|||||||
firstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
|
firstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
|
||||||
} else {
|
} else {
|
||||||
// Если пиньинь нет, используем первый иероглиф
|
// Если пиньинь нет, используем первый иероглиф
|
||||||
firstChar = name.trim().charAt(0);
|
firstChar = sight.name.trim().charAt(0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
firstChar = firstChar.toUpperCase();
|
firstChar = firstChar.toUpperCase();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React from "react";
|
||||||
|
|
||||||
const LANGUAGES = {
|
const LANGUAGES = {
|
||||||
EN: "en",
|
EN: "en",
|
||||||
@@ -18,26 +18,6 @@ const ListHeader = function ListHeader({
|
|||||||
isTransferWidgetOpen,
|
isTransferWidgetOpen,
|
||||||
onBackToNearest,
|
onBackToNearest,
|
||||||
}) {
|
}) {
|
||||||
const [isIdle, setIsIdle] = useState(false);
|
|
||||||
const timerRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const resetTimer = () => {
|
|
||||||
setIsIdle(false);
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = setTimeout(() => setIsIdle(true), 15000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const events = ["pointermove", "pointerdown", "touchstart", "keydown"];
|
|
||||||
events.forEach((e) => window.addEventListener(e, resetTimer));
|
|
||||||
resetTimer();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
events.forEach((e) => window.removeEventListener(e, resetTimer));
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getTitle = () => {
|
const getTitle = () => {
|
||||||
return selectedLanguageRight === LANGUAGES.RU
|
return selectedLanguageRight === LANGUAGES.RU
|
||||||
? "Достопримечательности"
|
? "Достопримечательности"
|
||||||
@@ -61,11 +41,14 @@ const ListHeader = function ListHeader({
|
|||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="20"
|
width="21"
|
||||||
height="20"
|
height="12"
|
||||||
viewBox="0 0 21 12"
|
viewBox="0 0 21 12"
|
||||||
fill="none"
|
fill="none"
|
||||||
className={`chevron-svg${isOpen ? " is-open" : ""}${isIdle ? " is-idle" : ""}`}
|
style={{
|
||||||
|
transform: isOpen ? "rotate(180deg)" : "rotate(0deg)",
|
||||||
|
transition: "transform 0.15s ease-in-out",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<g clipPath="url(#clip0_658_91932)">
|
<g clipPath="url(#clip0_658_91932)">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const SightComponent = function SightComponent({
|
|||||||
aria-label={`Выбрать достопримечательность ${title}`}
|
aria-label={`Выбрать достопримечательность ${title}`}
|
||||||
>
|
>
|
||||||
<div className="sight-image">{renderThumbnail()}</div>
|
<div className="sight-image">{renderThumbnail()}</div>
|
||||||
<div className="sight-title">
|
<div className="sight-title" title={title}>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef, useMemo, useLayoutEffect, useCallback } from "react";
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useGeolocationStore } from "../../stores";
|
import { useGeolocationStore } from "../../stores";
|
||||||
@@ -16,10 +16,6 @@ import { ThreeViewErrorBoundary } from "../ThreeViewErrorBoundary";
|
|||||||
import { apiStore } from "../../api/ApiStore/store";
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
import { ReactMarkdownComponent } from "../ReactMarkdown";
|
import { ReactMarkdownComponent } from "../ReactMarkdown";
|
||||||
import { TouchableLayout } from "../TouchableLayout";
|
import { TouchableLayout } from "../TouchableLayout";
|
||||||
import rotate3DIcon from "../../assets/icons/three-view-rotate.svg";
|
|
||||||
import zoom3DIcon from "../../assets/icons/three-view-zoom.svg";
|
|
||||||
import pan3DIcon from "../../assets/icons/three-view-pan.svg";
|
|
||||||
import subtractHomeIcon from "../../assets/icons/subtract-home.svg";
|
|
||||||
|
|
||||||
const Watermark = ({ path }) => {
|
const Watermark = ({ path }) => {
|
||||||
if (!path) return null;
|
if (!path) return null;
|
||||||
@@ -41,68 +37,10 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
const [isFullscreen3D, setIsFullscreen3D] = useState(false);
|
const [isFullscreen3D, setIsFullscreen3D] = useState(false);
|
||||||
const [fullscreenFileUrl, setFullscreenFileUrl] = useState("");
|
const [fullscreenFileUrl, setFullscreenFileUrl] = useState("");
|
||||||
const [threeViewResetKey, setThreeViewResetKey] = useState(0);
|
const [threeViewResetKey, setThreeViewResetKey] = useState(0);
|
||||||
const [isSwitching, setIsSwitching] = useState(false);
|
|
||||||
const threeViewControlRef = useRef(null);
|
const threeViewControlRef = useRef(null);
|
||||||
const mediaCache = useRef({});
|
const mediaCache = useRef({});
|
||||||
const prevSightIdRef = useRef(null);
|
|
||||||
|
|
||||||
const textWrapperRef = useRef(null);
|
const textWrapperRef = useRef(null);
|
||||||
const mediaStackRef = useRef(null);
|
|
||||||
const titleRef = useRef(null);
|
|
||||||
const switchTimerRef = useRef(null);
|
|
||||||
const homeButtonRef = useRef(null);
|
|
||||||
const menuRef = useRef(null);
|
|
||||||
const [menuNeedsScroll, setMenuNeedsScroll] = useState(false);
|
|
||||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
|
||||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
|
||||||
|
|
||||||
const currentSection = articleSections?.[selectedSection] ?? null;
|
|
||||||
|
|
||||||
const requestSection = (target) => {
|
|
||||||
setIsFullscreen3D(false);
|
|
||||||
if (target === selectedSection) return;
|
|
||||||
if (switchTimerRef.current) clearTimeout(switchTimerRef.current);
|
|
||||||
setIsSwitching(true);
|
|
||||||
switchTimerRef.current = setTimeout(() => {
|
|
||||||
setSelectedSection(target);
|
|
||||||
setIsSwitching(false);
|
|
||||||
switchTimerRef.current = null;
|
|
||||||
}, 180);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateScrollState = useCallback(() => {
|
|
||||||
const menu = menuRef.current;
|
|
||||||
if (!menu) return;
|
|
||||||
setCanScrollLeft(menu.scrollLeft > 2);
|
|
||||||
setCanScrollRight(menu.scrollLeft + menu.clientWidth < menu.scrollWidth - 2);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const menu = menuRef.current;
|
|
||||||
if (!menu || !menuNeedsScroll) {
|
|
||||||
setCanScrollLeft(false);
|
|
||||||
setCanScrollRight(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateScrollState();
|
|
||||||
menu.addEventListener('scroll', updateScrollState);
|
|
||||||
return () => menu.removeEventListener('scroll', updateScrollState);
|
|
||||||
}, [menuNeedsScroll, updateScrollState]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const menu = menuRef.current;
|
|
||||||
if (!menu) return;
|
|
||||||
const children = Array.from(menu.querySelectorAll('.sight-frame-menu-point'));
|
|
||||||
if (children.length < 2) {
|
|
||||||
setMenuNeedsScroll(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const style = getComputedStyle(menu);
|
|
||||||
const availableWidth = menu.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
|
||||||
const totalChildrenWidth = children.reduce((sum, el) => sum + el.offsetWidth, 0);
|
|
||||||
const evenGap = (availableWidth - totalChildrenWidth) / (children.length + 1);
|
|
||||||
setMenuNeedsScroll(evenGap < 10);
|
|
||||||
}, [articleSections]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
routeSights,
|
routeSights,
|
||||||
@@ -142,16 +80,11 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const { signal } = controller;
|
const { signal } = controller;
|
||||||
|
|
||||||
const isSameSight = prevSightIdRef.current === sight_id;
|
setIsLoadingContent(true);
|
||||||
prevSightIdRef.current = sight_id;
|
setContentError(null);
|
||||||
|
setModelAspectRatio(null);
|
||||||
if (!isSameSight) {
|
setIsVisible(false);
|
||||||
setIsLoadingContent(true);
|
setMediaData({});
|
||||||
setContentError(null);
|
|
||||||
setModelAspectRatio(null);
|
|
||||||
setIsVisible(false);
|
|
||||||
setMediaData({});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sight_id) {
|
if (!sight_id) {
|
||||||
setContentError("Не указан ID статьи.");
|
setContentError("Не указан ID статьи.");
|
||||||
@@ -229,18 +162,12 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
const introSection = {
|
const introSection = {
|
||||||
id: media?.id || "intro-title",
|
id: media?.id || "intro-title",
|
||||||
heading:
|
heading:
|
||||||
sight?.name || sight_name || "Название достопримечательности",
|
sight?.short_name || sight?.name || sight_name || "Название достопримечательности",
|
||||||
body: "",
|
body: "",
|
||||||
};
|
};
|
||||||
const allSections = [introSection, ...rightArticles];
|
const allSections = [introSection, ...rightArticles];
|
||||||
setArticleSections(allSections);
|
setArticleSections(allSections);
|
||||||
|
|
||||||
if (isSameSight) {
|
|
||||||
setIsLoadingContent(false);
|
|
||||||
setIsVisible(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheKey = `${sight_id}_${selectedLanguageRight}`;
|
const cacheKey = `${sight_id}_${selectedLanguageRight}`;
|
||||||
if (mediaCache.current[cacheKey]) {
|
if (mediaCache.current[cacheKey]) {
|
||||||
setMediaData(mediaCache.current[cacheKey]);
|
setMediaData(mediaCache.current[cacheKey]);
|
||||||
@@ -281,43 +208,9 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedSection(0);
|
setSelectedSection(0);
|
||||||
setIsSwitching(false);
|
|
||||||
if (switchTimerRef.current) {
|
|
||||||
clearTimeout(switchTimerRef.current);
|
|
||||||
switchTimerRef.current = null;
|
|
||||||
}
|
|
||||||
}, [sight_id]);
|
}, [sight_id]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
const currentSection = articleSections?.[selectedSection] ?? null;
|
||||||
const scrollable = textWrapperRef.current?.querySelector(".scrollable");
|
|
||||||
if (scrollable) scrollable.scrollTop = 0;
|
|
||||||
}, [selectedSection]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isFullscreen3D) return;
|
|
||||||
|
|
||||||
let idleSeconds = 0;
|
|
||||||
const intervalId = setInterval(() => {
|
|
||||||
idleSeconds += 1;
|
|
||||||
|
|
||||||
if (idleSeconds >= 60) {
|
|
||||||
setIsFullscreen3D(false);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
const resetIdle = () => {
|
|
||||||
idleSeconds = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const events = ["pointerdown", "pointermove", "touchstart", "keydown"];
|
|
||||||
events.forEach((e) => window.addEventListener(e, resetIdle, { passive: true }));
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
events.forEach((e) => window.removeEventListener(e, resetIdle));
|
|
||||||
};
|
|
||||||
}, [isFullscreen3D]);
|
|
||||||
|
|
||||||
|
|
||||||
const renderCurrentMedia = () => {
|
const renderCurrentMedia = () => {
|
||||||
if (!articleSections || Object.keys(mediaData).length === 0) {
|
if (!articleSections || Object.keys(mediaData).length === 0) {
|
||||||
@@ -347,7 +240,9 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
alt=""
|
alt=""
|
||||||
className={className}
|
className={className}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
console.warn(`Failed to load image: ${currentMediaData.path}`);
|
console.warn(
|
||||||
|
`Failed to load image: ${currentMediaData.path}`,
|
||||||
|
);
|
||||||
e.target.style.display = "none";
|
e.target.style.display = "none";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -362,7 +257,9 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
playsInline
|
playsInline
|
||||||
className={className}
|
className={className}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
console.warn(`Failed to load video: ${currentMediaData.path}`);
|
console.warn(
|
||||||
|
`Failed to load video: ${currentMediaData.path}`,
|
||||||
|
);
|
||||||
e.target.style.display = "none";
|
e.target.style.display = "none";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -412,20 +309,27 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="three-d-control-btn"
|
className="three-d-control-btn"
|
||||||
onPointerUp={() => threeViewControlRef.current?.zoomOut?.()}
|
title="Уменьшить"
|
||||||
|
onPointerUp={() =>
|
||||||
|
threeViewControlRef.current?.zoomOut?.()
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<MinusIcon />
|
<MinusIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="three-d-control-btn"
|
className="three-d-control-btn"
|
||||||
onPointerUp={() => threeViewControlRef.current?.zoomIn?.()}
|
title="Увеличить"
|
||||||
|
onPointerUp={() =>
|
||||||
|
threeViewControlRef.current?.zoomIn?.()
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="three-d-control-btn"
|
className="three-d-control-btn"
|
||||||
|
title={isFullscreen3D ? "Свернуть" : "Развернуть"}
|
||||||
onPointerUp={() => {
|
onPointerUp={() => {
|
||||||
if (isFullscreen3D) {
|
if (isFullscreen3D) {
|
||||||
setIsFullscreen3D(false);
|
setIsFullscreen3D(false);
|
||||||
@@ -440,6 +344,11 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className={"fullscreen-3d-button"}
|
className={"fullscreen-3d-button"}
|
||||||
|
title={
|
||||||
|
isFullscreen3D
|
||||||
|
? "Закрыть полноэкранный режим"
|
||||||
|
: "Открыть в полноэкранном режиме"
|
||||||
|
}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -455,100 +364,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{isFullscreen3D && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 94,
|
|
||||||
right: 10,
|
|
||||||
zIndex: 10,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="cluster-sights-list"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(var(--carrier-right-rgb, 0, 111, 58), 0.4)`,
|
|
||||||
backdropFilter: "blur(10px)",
|
|
||||||
borderRadius: "8px",
|
|
||||||
width: 200,
|
|
||||||
boxShadow:
|
|
||||||
"0 0 0 1px rgba(255, 255, 255, 0.3) inset, 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
padding: "8px 13px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
label: "Вращать",
|
|
||||||
icon: (
|
|
||||||
<img
|
|
||||||
src={rotate3DIcon}
|
|
||||||
alt=""
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Приблизить / Отдалить",
|
|
||||||
icon: (
|
|
||||||
<img src={zoom3DIcon} alt="" width="14" height="14" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Переместить",
|
|
||||||
icon: (
|
|
||||||
<img src={pan3DIcon} alt="" width="14" height="14" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
].map((item, index, arr) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
height: "30px",
|
|
||||||
userSelect: "none",
|
|
||||||
touchAction: "none",
|
|
||||||
padding: "0 4px",
|
|
||||||
borderBottom:
|
|
||||||
index < arr.length - 1
|
|
||||||
? "1px solid rgba(255, 255, 255, 0.1)"
|
|
||||||
: "none",
|
|
||||||
transition: "background-color 0.2s",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
marginRight: "8px",
|
|
||||||
flexShrink: 0,
|
|
||||||
lineHeight: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
fontSize: "12px",
|
|
||||||
lineHeight: "1.5",
|
|
||||||
fontWeight: "400",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@@ -613,15 +428,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
const processedSightName = useMemo(() => {
|
const processedSightName = useMemo(() => {
|
||||||
if (!sight_name) return sight_name;
|
if (!sight_name) return sight_name;
|
||||||
|
|
||||||
if (sight_name.includes("\n")) {
|
|
||||||
return sight_name.split("\n").map((line, i) => (
|
|
||||||
<React.Fragment key={i}>
|
|
||||||
{i > 0 && <br />}
|
|
||||||
{line}
|
|
||||||
</React.Fragment>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
const namePattern =
|
const namePattern =
|
||||||
/([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g;
|
/([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g;
|
||||||
|
|
||||||
@@ -648,7 +454,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
|
|
||||||
const titleLineHeight = useMemo(() => {
|
const titleLineHeight = useMemo(() => {
|
||||||
if (!sight_name) return "120%";
|
if (!sight_name) return "120%";
|
||||||
const textLength = sight_name.replace(/\n/g, "").length;
|
const textLength = sight_name.length;
|
||||||
const calculatedLineHeight = Math.max(
|
const calculatedLineHeight = Math.max(
|
||||||
100,
|
100,
|
||||||
Math.min(120, 120 - (textLength / 10) * 1),
|
Math.min(120, 120 - (textLength / 10) * 1),
|
||||||
@@ -657,145 +463,146 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
}, [sight_name]);
|
}, [sight_name]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
|
className={`sight-frame ${isVisible ? "is-visible" : ""} ${
|
||||||
|
isFullscreen3D && isCurrentMedia3D ? "three-d-fullscreen" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sightData?.watermark_lu && !isFullscreen3D && (
|
||||||
|
<Watermark path={ContentAPI.getMediaPath(sightData.watermark_lu)} />
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={`sight-frame ${isVisible ? "is-visible" : ""} ${
|
className={`sight-frame-media-stack ${
|
||||||
isFullscreen3D && isCurrentMedia3D ? "three-d-fullscreen" : ""
|
isCurrentMedia3D ? "three-d-view" : ""
|
||||||
}`}
|
}`}
|
||||||
|
style={getMediaStackStyle()}
|
||||||
>
|
>
|
||||||
{sightData?.watermark_lu && !isFullscreen3D && (
|
{contentError ? (
|
||||||
<Watermark path={ContentAPI.getMediaPath(sightData.watermark_lu)} />
|
<div className="sight-frame-image-placeholder error-message">
|
||||||
|
{contentError}
|
||||||
|
</div>
|
||||||
|
) : isLoadingContent || !articleSections ? (
|
||||||
|
<div className="sight-frame-image-placeholder">
|
||||||
|
Загрузка контента...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
renderCurrentMedia()
|
||||||
)}
|
)}
|
||||||
<div
|
</div>
|
||||||
ref={mediaStackRef}
|
<div className="sight-frame-content">
|
||||||
className={`sight-frame-media-stack ${
|
{contentError ? (
|
||||||
isCurrentMedia3D ? "three-d-view" : ""
|
<p className="error-message">{contentError}</p>
|
||||||
}`}
|
) : !currentSection ? (
|
||||||
style={getMediaStackStyle()}
|
<p>Информация отсутствует.</p>
|
||||||
>
|
) : (
|
||||||
{contentError ? (
|
<>
|
||||||
<div className="sight-frame-image-placeholder error-message">
|
{!isFullscreen3D && (
|
||||||
{contentError}
|
<div
|
||||||
</div>
|
className={`sight-frame-title ${
|
||||||
) : isLoadingContent || !articleSections ? (
|
selectedSection === 0 ? "intro-title" : ""
|
||||||
<div className="sight-frame-image-placeholder">
|
}`}
|
||||||
Загрузка контента...
|
style={{ lineHeight: titleLineHeight }}
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
renderCurrentMedia()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={`sight-frame-content ${isSwitching ? "is-switching" : ""}`}>
|
|
||||||
{contentError ? (
|
|
||||||
<p className="error-message">{contentError}</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{!isFullscreen3D && (
|
|
||||||
<div
|
|
||||||
ref={titleRef}
|
|
||||||
className={`sight-frame-title ${
|
|
||||||
selectedSection === 0 ? "intro-title" : ""
|
|
||||||
}`}
|
|
||||||
style={{ lineHeight: titleLineHeight }}
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
key={selectedSection}
|
|
||||||
className="fade-in-text"
|
|
||||||
style={{
|
|
||||||
whiteSpace: "normal",
|
|
||||||
wordBreak: "break-word",
|
|
||||||
overflowWrap: "break-word",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedSection === 0
|
|
||||||
? processedSightName || sightData?.name
|
|
||||||
: sightData?.short_name || sightData?.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<TouchableLayout
|
|
||||||
key={selectedSection}
|
|
||||||
className={`sight-frame-text-wrapper ${selectedSection === 0 ? "is-intro" : ""}`}
|
|
||||||
ref={textWrapperRef}
|
|
||||||
>
|
>
|
||||||
<div className="sight-frame-text fade-in-text">
|
<p
|
||||||
{isLoadingContent || !currentSection ? (
|
style={{
|
||||||
<div className="sight-frame-loading-placeholder" />
|
whiteSpace: "normal",
|
||||||
) : (
|
wordBreak: "break-word",
|
||||||
selectedSection !== 0 && (
|
overflowWrap: "break-word",
|
||||||
<ReactMarkdownComponent value={currentSection.body} />
|
}}
|
||||||
)
|
>
|
||||||
)}
|
{selectedSection === 0 ? processedSightName : sight_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedSection !== 0 && (
|
||||||
|
<TouchableLayout
|
||||||
|
className="sight-frame-text-wrapper"
|
||||||
|
ref={textWrapperRef}
|
||||||
|
maxHeight="calc(80vh - 354px)"
|
||||||
|
>
|
||||||
|
<div className="sight-frame-text">
|
||||||
|
<ReactMarkdownComponent value={currentSection.body} />
|
||||||
</div>
|
</div>
|
||||||
</TouchableLayout>
|
</TouchableLayout>
|
||||||
</>
|
)}
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="sight-frame-menu">
|
||||||
className="sight-frame-menu-wrapper"
|
{selectedSection !== 0 && (
|
||||||
style={{
|
<div
|
||||||
opacity: isVisible ? 1 : 0,
|
style={{
|
||||||
transition: "opacity 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.08s",
|
position: "absolute",
|
||||||
pointerEvents: isVisible ? "auto" : "none",
|
left: "10px",
|
||||||
}}
|
marginTop: "-4.5px",
|
||||||
>
|
zIndex: 1,
|
||||||
<div className="sight-frame-menu-fade left" style={{ opacity: canScrollLeft ? 1 : 0 }} />
|
paddingLeft: "15px",
|
||||||
<div className="sight-frame-menu-fade right" style={{ opacity: canScrollRight ? 1 : 0 }} />
|
paddingRight: "7.5px",
|
||||||
<div
|
paddingTop: "4.5px",
|
||||||
className="sight-frame-menu"
|
paddingBottom: "4.5px",
|
||||||
ref={menuRef}
|
cursor: "pointer",
|
||||||
style={menuNeedsScroll ? { justifyContent: 'flex-start' } : undefined}
|
}}
|
||||||
>
|
onPointerUp={() => setSelectedSection(0)}
|
||||||
{contentError ? (
|
>
|
||||||
<p className="error-message">{contentError}</p>
|
<svg
|
||||||
) : (
|
width="20"
|
||||||
articleSections &&
|
height="25"
|
||||||
articleSections.length > 1 &&
|
viewBox="0 0 20 25"
|
||||||
articleSections.slice(1).map((section, index) => (
|
fill="none"
|
||||||
<div
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
onPointerUp={() => requestSection(index + 1)}
|
style={{ display: "block" }}
|
||||||
key={section.id || section.heading || index}
|
>
|
||||||
data-label={section.heading}
|
<defs>
|
||||||
className={`sight-frame-menu-point ${
|
<linearGradient
|
||||||
index + 1 === selectedSection ? "active" : ""
|
id="sightFrameGradient3"
|
||||||
}`}
|
x1="0%"
|
||||||
role="button"
|
y1="0%"
|
||||||
tabIndex="0"
|
x2="100%"
|
||||||
>
|
y2="100%"
|
||||||
{section.heading}
|
gradientUnits="userSpaceOnUse"
|
||||||
</div>
|
>
|
||||||
))
|
<stop offset="0%" stopColor="rgba(255, 255, 255, 0.2)" />
|
||||||
)}
|
<stop offset="100%" stopColor="rgba(255, 255, 255, 0)" />
|
||||||
</div>
|
</linearGradient>
|
||||||
|
<clipPath id="clip0_662_97446">
|
||||||
|
<rect
|
||||||
|
width="20"
|
||||||
|
height="25"
|
||||||
|
fill="white"
|
||||||
|
transform="translate(12.5 0.5) rotate(90)"
|
||||||
|
/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g clipPath="url(#clip0_662_97446)">
|
||||||
|
<path
|
||||||
|
d="M4.03158 11.0738C4.21879 11.2087 4.34702 11.2766 4.44531 11.3747C6.93048 13.8687 9.41098 16.3665 11.8962 18.8605C12.3408 19.3067 12.5186 19.8207 12.3361 20.4339C12.0281 21.4658 10.7776 21.8393 9.95295 21.1498C9.86309 21.0743 9.78165 20.9894 9.69928 20.9064C6.85279 18.0446 4.00537 15.1817 1.15982 12.3189C0.280876 11.4341 0.281813 10.6456 1.16169 9.75982C4.04 6.86305 6.91457 3.96155 9.80786 1.07986C10.0597 0.828952 10.4144 0.619547 10.7561 0.537482C11.4019 0.382786 12.015 0.72142 12.3193 1.28644C12.6263 1.85334 12.5392 2.56079 12.0806 3.05129C11.7286 3.4286 11.3561 3.78704 10.991 4.15209C8.79601 6.35557 6.60194 8.55904 4.40599 10.7606C4.32362 10.8436 4.22721 10.9106 4.03158 11.0729L4.03158 11.0738Z"
|
||||||
|
fill="url(#sightFrameGradient3)"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contentError ? (
|
||||||
|
<p className="error-message">{contentError}</p>
|
||||||
|
) : (
|
||||||
|
articleSections &&
|
||||||
|
articleSections.length > 1 &&
|
||||||
|
articleSections.slice(1).map((section, index) => (
|
||||||
|
<div
|
||||||
|
onPointerUp={() => setSelectedSection(index + 1)}
|
||||||
|
key={section.id || section.heading || index}
|
||||||
|
className={`sight-frame-menu-point ${
|
||||||
|
index + 1 === selectedSection ? "active" : ""
|
||||||
|
}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex="0"
|
||||||
|
>
|
||||||
|
{section.heading}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
</div>
|
||||||
ref={homeButtonRef}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: "10px",
|
|
||||||
bottom: "115px",
|
|
||||||
zIndex: 3,
|
|
||||||
paddingLeft: "15px",
|
|
||||||
paddingRight: "7.5px",
|
|
||||||
paddingTop: "4.5px",
|
|
||||||
paddingBottom: "4.5px",
|
|
||||||
cursor: "pointer",
|
|
||||||
opacity: selectedSection !== 0 ? 1 : 0,
|
|
||||||
transition: "opacity 0.3s ease",
|
|
||||||
pointerEvents: selectedSection !== 0 ? "auto" : "none",
|
|
||||||
}}
|
|
||||||
onPointerUp={() => requestSection(0)}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={subtractHomeIcon}
|
|
||||||
alt=""
|
|
||||||
width="24"
|
|
||||||
height="21"
|
|
||||||
style={{ display: "block" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -96,35 +96,20 @@ const TransferWidget = observer(function TransferWidget({
|
|||||||
|
|
||||||
const getTransferLabel = () => {
|
const getTransferLabel = () => {
|
||||||
if (selectedLanguageRight === "ru") {
|
if (selectedLanguageRight === "ru") {
|
||||||
return stationName ? (
|
return stationName
|
||||||
<>
|
? `Пересадки остановки ${stationName}:`
|
||||||
<div>Пересадка на остановке</div>
|
: "Ближайшая остановка не обнаружена";
|
||||||
<div>«{stationName}»:</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Ближайшая остановка не обнаружена"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedLanguageRight === "en") {
|
if (selectedLanguageRight === "en") {
|
||||||
return stationName ? (
|
return stationName
|
||||||
<>
|
? `Available transfers at station ${stationName}`
|
||||||
<div>Transfer at stop</div>
|
: "Nearest station not found";
|
||||||
<div>«{stationName}»:</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Nearest station not found"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return stationName ? (
|
return stationName
|
||||||
<>
|
? `在车站可用的换乘:${stationName}`
|
||||||
<div>在站点换乘</div>
|
: "最近的站点未找到";
|
||||||
<div>«{stationName}»:</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"最近的站点未找到"
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNoTransfersMessage = () => {
|
const getNoTransfersMessage = () => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import "../styles/Loader.css";
|
import "../styles/Loader.css";
|
||||||
import { getMediaUrl } from "../api/apiConfig";
|
|
||||||
|
|
||||||
const loaderVideoPath = new URL("../assets/video/taganai.mp4", import.meta.url)
|
const loaderVideoPath = new URL("../assets/video/taganai.mp4", import.meta.url)
|
||||||
.href;
|
.href;
|
||||||
@@ -10,14 +9,8 @@ const Loader = ({
|
|||||||
loadingStatus = null,
|
loadingStatus = null,
|
||||||
loadingProgress = null,
|
loadingProgress = null,
|
||||||
onToggleDebug,
|
onToggleDebug,
|
||||||
videoId = null,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [showLoadingInfo, setShowLoadingInfo] = useState(false);
|
const [showLoadingInfo, setShowLoadingInfo] = useState(false);
|
||||||
const [isCustomVideoError, setIsCustomVideoError] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsCustomVideoError(false);
|
|
||||||
}, [videoId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event) => {
|
||||||
@@ -62,12 +55,7 @@ const Loader = ({
|
|||||||
loop
|
loop
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
src={
|
src={loaderVideoPath}
|
||||||
videoId && !isCustomVideoError
|
|
||||||
? getMediaUrl(videoId)
|
|
||||||
: loaderVideoPath
|
|
||||||
}
|
|
||||||
onError={() => setIsCustomVideoError(true)}
|
|
||||||
alt="Loading"
|
alt="Loading"
|
||||||
className="error-gif"
|
className="error-gif"
|
||||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
export declare const OverlayScrollbarsWrapper: React.ForwardRefExoticComponent<
|
|
||||||
React.PropsWithChildren<{
|
|
||||||
className?: string;
|
|
||||||
onScroll?: (event: Event) => void;
|
|
||||||
overflowX?: string;
|
|
||||||
overflowY?: string;
|
|
||||||
scrollbarVisibility?: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}> &
|
|
||||||
React.RefAttributes<HTMLElement>
|
|
||||||
>;
|
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.react-markdown-container blockquote {
|
.react-markdown-container blockquote {
|
||||||
border-left: 4px solid var(--carrier-main, #006F3A);
|
border-left: 4px solid #006F3A;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import "react";
|
import React from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import rehypeRaw from "rehype-raw";
|
import rehypeRaw from "rehype-raw";
|
||||||
import "./ReactMarkdown.css";
|
import "./ReactMarkdown.css";
|
||||||
|
|||||||
@@ -6,30 +6,22 @@ export const SimulationSettings = observer(() => {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: "fixed", top: 12, right: 12, zIndex: 2147483646 }}>
|
<div style={{ position: "absolute", top: 12, right: 12, zIndex: 10010 }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
style={{
|
style={{
|
||||||
width: 36, height: 36, borderRadius: 6,
|
width: 36, height: 36, borderRadius: 6,
|
||||||
border: "1px solid rgba(255,255,255,0.25)",
|
border: "1px solid rgba(255,255,255,0.25)",
|
||||||
background: open ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.5)",
|
background: open ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.5)",
|
||||||
color: "white", cursor: "pointer",
|
color: "white", cursor: "pointer", fontSize: 18,
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
padding: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
⚙
|
||||||
<path
|
|
||||||
d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.92c.04-.34.07-.69.07-1.08s-.03-.73-.07-1.08l2.33-1.82c.21-.16.27-.46.13-.7l-2.21-3.83a.55.55 0 0 0-.68-.22l-2.75 1.1a8.1 8.1 0 0 0-1.86-1.08l-.42-2.93A.545.545 0 0 0 14 2h-4c-.27 0-.5.2-.54.46l-.42 2.93c-.68.28-1.3.65-1.86 1.08L4.43 5.37a.543.543 0 0 0-.68.22L1.54 9.42c-.14.24-.08.54.13.7l2.33 1.82c-.04.35-.07.7-.07 1.08s.03.73.07 1.08L1.67 15.92c-.21.16-.27.46-.13.7l2.21 3.83c.14.24.43.31.68.22l2.75-1.1c.56.43 1.18.8 1.86 1.08l.42 2.93c.04.26.27.46.54.46h4c.27 0 .5-.2.54-.46l.42-2.93c.68-.28 1.3-.65 1.86-1.08l2.75 1.1c.25.09.54.02.68-.22l2.21-3.83c.14-.24.08-.54-.13-.7l-2.33-1.82Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: "absolute", top: 42, right: 0,
|
marginTop: 6, background: "rgba(20,20,20,0.9)",
|
||||||
background: "rgba(20,20,20,0.9)",
|
|
||||||
border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8,
|
border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8,
|
||||||
padding: "10px 12px", minWidth: 200, fontSize: 13,
|
padding: "10px 12px", minWidth: 200, fontSize: 13,
|
||||||
}}>
|
}}>
|
||||||
@@ -80,15 +72,6 @@ export const SimulationSettings = observer(() => {
|
|||||||
onClick={apiStore.toggleSimulationInstantMove}
|
onClick={apiStore.toggleSimulationInstantMove}
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* Хитбоксы */}
|
|
||||||
<Row>
|
|
||||||
<span>Хитбоксы</span>
|
|
||||||
<Toggle
|
|
||||||
on={apiStore.showHitboxes}
|
|
||||||
onClick={apiStore.toggleShowHitboxes}
|
|
||||||
/>
|
|
||||||
</Row>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
114deg,
|
114deg,
|
||||||
rgba(255, 255, 255, 0.1) 8.71%,
|
rgba(255, 255, 255, 0.1) 8.71%,
|
||||||
rgba(255, 255, 255, 0.05) 69.69%
|
rgba(255, 255, 255, 0.05) 69.69%
|
||||||
), var(--carrier-main, #006F3A);
|
), #006F3A;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ interface TouchableLayoutProps {
|
|||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
maxHeight?: string | number;
|
maxHeight?: string | number;
|
||||||
style?: React.CSSProperties;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
||||||
@@ -20,30 +19,20 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
|||||||
height: 60,
|
height: 60,
|
||||||
top: 0,
|
top: 0,
|
||||||
hasScroll: false,
|
hasScroll: false,
|
||||||
isAtTop: true,
|
|
||||||
isAtBottom: false,
|
|
||||||
});
|
});
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const update = useCallback(() => {
|
const update = useCallback(() => {
|
||||||
const el = scrollableRef.current;
|
const el = scrollableRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
if (el.closest('[data-height-animating="true"]')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sh = el.scrollHeight;
|
const sh = el.scrollHeight;
|
||||||
const ch = el.clientHeight;
|
const ch = el.clientHeight;
|
||||||
const st = el.scrollTop;
|
const st = el.scrollTop;
|
||||||
const th = ch;
|
const th = ch;
|
||||||
|
|
||||||
const isAtTop = st <= 0;
|
if (sh <= ch) {
|
||||||
const isAtBottom = st + ch >= sh - 1;
|
setState({ height: th, top: 0, hasScroll: false });
|
||||||
|
|
||||||
if (sh <= ch + 2) {
|
|
||||||
setState((prev) => ({ ...prev, hasScroll: false, isAtTop: true, isAtBottom: true }));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +41,7 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
|||||||
const scrollRange = sh - ch;
|
const scrollRange = sh - ch;
|
||||||
const top = range <= 0 ? 0 : (st / scrollRange) * range;
|
const top = range <= 0 ? 0 : (st / scrollRange) * range;
|
||||||
|
|
||||||
setState({ height: thumbHeight, top, hasScroll: true, isAtTop, isAtBottom });
|
setState({ height: thumbHeight, top, hasScroll: true });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,36 +57,22 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
el.addEventListener("scroll", schedule, { passive: true });
|
el.addEventListener("scroll", schedule, { passive: true });
|
||||||
el.addEventListener("layoutchange", schedule);
|
|
||||||
const ro = new ResizeObserver(schedule);
|
const ro = new ResizeObserver(schedule);
|
||||||
ro.observe(el);
|
ro.observe(el);
|
||||||
const mo = new MutationObserver(schedule);
|
|
||||||
mo.observe(el, { childList: true, subtree: true, characterData: true });
|
|
||||||
schedule();
|
schedule();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener("scroll", schedule);
|
el.removeEventListener("scroll", schedule);
|
||||||
el.removeEventListener("layoutchange", schedule);
|
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
mo.disconnect();
|
|
||||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||||
};
|
};
|
||||||
}, [update]);
|
}, [update]);
|
||||||
|
|
||||||
useEffect(() => {
|
return state;
|
||||||
if (!state.hasScroll) {
|
|
||||||
setVisible(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const t = setTimeout(() => setVisible(true), 250);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, [state.hasScroll]);
|
|
||||||
|
|
||||||
return { ...state, visible };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
||||||
({ children, className, maxHeight, style }, ref) => {
|
({ children, className, maxHeight }, ref) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||||
const trackRef = useRef<HTMLDivElement>(null);
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -259,12 +234,9 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
|||||||
};
|
};
|
||||||
}, [thumb.hasScroll]);
|
}, [thumb.hasScroll]);
|
||||||
|
|
||||||
const containerClassName = [
|
const containerClassName = className
|
||||||
"scrollable-container",
|
? `scrollable-container ${className}`
|
||||||
className,
|
: "scrollable-container";
|
||||||
thumb.isAtTop ? "is-at-top" : "",
|
|
||||||
thumb.isAtBottom ? "is-at-bottom" : "",
|
|
||||||
].filter(Boolean).join(" ");
|
|
||||||
|
|
||||||
const viewportStyle: React.CSSProperties = maxHeight
|
const viewportStyle: React.CSSProperties = maxHeight
|
||||||
? {
|
? {
|
||||||
@@ -274,27 +246,20 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
|||||||
: {};
|
: {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setRefs} className={containerClassName} style={style}>
|
<div ref={setRefs} className={containerClassName}>
|
||||||
<div className="scrollable-viewport" style={viewportStyle}>
|
<div className="scrollable-viewport" style={viewportStyle}>
|
||||||
<div ref={scrollableRef} className="scrollable">
|
<div ref={scrollableRef} className="scrollable">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<div
|
{thumb.hasScroll && (
|
||||||
ref={trackRef}
|
<div ref={trackRef} className="custom-scrollbar-track">
|
||||||
className="custom-scrollbar-track"
|
<div
|
||||||
style={{ opacity: thumb.visible ? 1 : 0 }}
|
ref={thumbRef}
|
||||||
>
|
className="custom-scrollbar-thumb"
|
||||||
<div
|
style={{ height: thumb.height, top: thumb.top }}
|
||||||
ref={thumbRef}
|
/>
|
||||||
className="custom-scrollbar-thumb"
|
</div>
|
||||||
style={{
|
)}
|
||||||
height: thumb.height,
|
|
||||||
top: thumb.top,
|
|
||||||
opacity: thumb.visible ? 1 : 0,
|
|
||||||
pointerEvents: thumb.visible ? "auto" : "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import windIcon from "../assets/weather-icons/wind.png";
|
|||||||
import humidityIcon from "../assets/weather-icons/humidity.png";
|
import humidityIcon from "../assets/weather-icons/humidity.png";
|
||||||
import { useGeolocationStore } from "../stores";
|
import { useGeolocationStore } from "../stores";
|
||||||
import { translateWeatherStatus } from "../utils/translateWeatherStatus";
|
import { translateWeatherStatus } from "../utils/translateWeatherStatus";
|
||||||
import { apiStore } from "../api/ApiStore";
|
|
||||||
|
|
||||||
function WeatherDataLine({ icon, value, unit }) {
|
function WeatherDataLine({ icon, value, unit }) {
|
||||||
return (
|
return (
|
||||||
@@ -44,11 +43,7 @@ const WeatherWidget = observer(() => {
|
|||||||
|
|
||||||
const fetchWeather = async () => {
|
const fetchWeather = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await weatherApi.getFormattedWeather({
|
const data = await weatherApi.getFormattedWeather();
|
||||||
lat: apiStore.context?.currentCoordinates?.latitude,
|
|
||||||
lng: apiStore.context?.currentCoordinates?.longitude,
|
|
||||||
cityCode: apiStore.city?.weather_city_code,
|
|
||||||
});
|
|
||||||
setWeather(data);
|
setWeather(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
setHasLoadedOnce(true);
|
setHasLoadedOnce(true);
|
||||||
|
|||||||
356
src/client/src/components/map/InfiniteCanvas.tsx
Normal file
356
src/client/src/components/map/InfiniteCanvas.tsx
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import { FederatedPointerEvent, FederatedWheelEvent } from "pixi.js";
|
||||||
|
import { ReactNode, useEffect, useState, useRef, useCallback } from "react";
|
||||||
|
import { useTransform } from "./transformContext";
|
||||||
|
import { BACKGROUND_COLOR, SCALE_FACTOR } from "../../assets/Constants";
|
||||||
|
import { useApplication } from "@pixi/react";
|
||||||
|
import { useGeolocation } from "../../context/GeolocationContext";
|
||||||
|
import ContentAPI from "../../api/content/content.api";
|
||||||
|
import React from "react";
|
||||||
|
import { useGeolocationStore } from "../../stores/hooks/useGeolocationStore";
|
||||||
|
import { useCameraAnimationStore } from "../../stores";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import debounce from "lodash/debounce";
|
||||||
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
|
|
||||||
|
export const InfiniteCanvas = observer(
|
||||||
|
({ children }: Readonly<{ children?: ReactNode }>) => {
|
||||||
|
const { route } = apiStore;
|
||||||
|
const {
|
||||||
|
position,
|
||||||
|
setPosition,
|
||||||
|
scale,
|
||||||
|
setScale,
|
||||||
|
setScreenCenter,
|
||||||
|
isAutoMode,
|
||||||
|
setIsAutoMode,
|
||||||
|
userActivityTimestamp,
|
||||||
|
updateUserActivity,
|
||||||
|
autoModeStartTimestamp,
|
||||||
|
setAutoModeStartTimestamp,
|
||||||
|
} = useTransform();
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
const applicationRef = useApplication();
|
||||||
|
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [startMousePosition, setStartMousePosition] = useState({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const activePointers = useRef(new Map<number, { x: number; y: number }>());
|
||||||
|
const [isPinching, setIsPinching] = useState(false);
|
||||||
|
// Add new useRef for storing initial pinch gesture data
|
||||||
|
const pinchStartData = useRef<{
|
||||||
|
distance: number;
|
||||||
|
midpoint: { x: number; y: number };
|
||||||
|
scale: number;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
} | null>(null);
|
||||||
|
// Keep these for backward compatibility, but we'll use pinchStartData for calculations
|
||||||
|
const [initialPinchDistance, setInitialPinchDistance] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
const [initialPinchMidpoint, setInitialPinchMidpoint] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [scaleMin, setScaleMin] = useState(0.1); // Default min scale
|
||||||
|
const [scaleMax, setScaleMax] = useState(3); // Default max scale
|
||||||
|
const store = useGeolocationStore();
|
||||||
|
const cameraAnimationStore = useCameraAnimationStore();
|
||||||
|
|
||||||
|
// Add debounced version of syncState to reduce jittering
|
||||||
|
const syncStateDebounced = useRef(
|
||||||
|
debounce((pos, zoom) => {
|
||||||
|
cameraAnimationStore.syncState(pos, zoom);
|
||||||
|
}, 16) // ~60fps
|
||||||
|
).current;
|
||||||
|
|
||||||
|
// Функция для плавного ограничения масштаба
|
||||||
|
const getSmoothScale = (targetScale: number, currentScale: number) => {
|
||||||
|
if (isAutoMode) return targetScale; // В авто режиме без ограничений
|
||||||
|
|
||||||
|
// Плавное ограничение с затуханием
|
||||||
|
const damping = 0.3; // Коэффициент затухания (0-1)
|
||||||
|
|
||||||
|
if (targetScale < scaleMin) {
|
||||||
|
// Плавное замедление при приближении к минимальному масштабу
|
||||||
|
const distance = scaleMin - targetScale;
|
||||||
|
const dampedDistance = distance * damping;
|
||||||
|
return Math.max(scaleMin, targetScale + dampedDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetScale > scaleMax) {
|
||||||
|
// Плавное замедление при приближении к максимальному масштабу
|
||||||
|
const distance = targetScale - scaleMax;
|
||||||
|
const dampedDistance = distance * damping;
|
||||||
|
return Math.min(scaleMax, targetScale - dampedDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Плавное возвращение к границам, если текущий масштаб за пределами
|
||||||
|
if (currentScale < scaleMin) {
|
||||||
|
// Плавно возвращаемся к минимальному масштабу
|
||||||
|
const distance = scaleMin - currentScale;
|
||||||
|
const dampedDistance = distance * damping;
|
||||||
|
return Math.min(targetScale, currentScale + dampedDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentScale > scaleMax) {
|
||||||
|
// Плавно возвращаемся к максимальному масштабу
|
||||||
|
const distance = currentScale - scaleMax;
|
||||||
|
const dampedDistance = distance * damping;
|
||||||
|
return Math.max(targetScale, currentScale - dampedDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetScale;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserActivity = () => {
|
||||||
|
updateUserActivity();
|
||||||
|
if (isAutoMode) {
|
||||||
|
setIsAutoMode(false);
|
||||||
|
}
|
||||||
|
// При любом действии пользователя останавливаем анимацию
|
||||||
|
cameraAnimationStore.stopAnimation();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Автоматический режим - таймер для включения и управление масштабом
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const timeSinceActivity = Date.now() - userActivityTimestamp;
|
||||||
|
if (timeSinceActivity >= 5000 && !isAutoMode) {
|
||||||
|
// 5 секунд бездействия - включаем авто режим
|
||||||
|
if (loaded) {
|
||||||
|
setIsAutoMode(true);
|
||||||
|
setAutoModeStartTimestamp(Date.now()); // Записываем время включения автопреследования
|
||||||
|
// Убираем мгновенную установку масштаба - теперь это делает CameraAnimationStore плавно
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000); // Проверяем каждую секунду
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [
|
||||||
|
userActivityTimestamp,
|
||||||
|
isAutoMode,
|
||||||
|
setIsAutoMode,
|
||||||
|
setScale,
|
||||||
|
setAutoModeStartTimestamp,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchRouteData() {
|
||||||
|
try {
|
||||||
|
const newScaleMin = route?.scale_min! / SCALE_FACTOR;
|
||||||
|
const newScaleMax = route?.scale_max! / SCALE_FACTOR;
|
||||||
|
|
||||||
|
setScaleMin(newScaleMin);
|
||||||
|
setScaleMax(newScaleMax);
|
||||||
|
setLoaded(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"Ошибка загрузки данных маршрута для scaleMin/Max:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchRouteData();
|
||||||
|
}, [route]);
|
||||||
|
|
||||||
|
// Убираем жесткое ограничение масштаба - теперь используется плавное ограничение
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = applicationRef?.app.canvas;
|
||||||
|
if (!canvas) return;
|
||||||
|
const canvasRect = canvas.getBoundingClientRect();
|
||||||
|
const canvasLeft = canvasRect.left;
|
||||||
|
const canvasTop = canvasRect.top;
|
||||||
|
const centerX = window.innerWidth / 2 - canvasLeft;
|
||||||
|
const centerY = window.innerHeight / 2 - canvasTop;
|
||||||
|
setScreenCenter({ x: centerX, y: centerY });
|
||||||
|
}, [applicationRef?.app.canvas, setScreenCenter]);
|
||||||
|
|
||||||
|
const handlePointerDown = (e: FederatedPointerEvent) => {
|
||||||
|
handleUserActivity();
|
||||||
|
|
||||||
|
activePointers.current.set(e.pointerId, { x: e.globalX, y: e.globalY });
|
||||||
|
|
||||||
|
if (activePointers.current.size === 1) {
|
||||||
|
setIsPinching(false);
|
||||||
|
setInitialPinchDistance(null);
|
||||||
|
setInitialPinchMidpoint(null);
|
||||||
|
pinchStartData.current = null;
|
||||||
|
setIsDragging(true);
|
||||||
|
setStartPosition({ x: position.x, y: position.y });
|
||||||
|
setStartMousePosition({ x: e.globalX, y: e.globalY });
|
||||||
|
} else if (activePointers.current.size === 2) {
|
||||||
|
setIsDragging(false); // Останавливаем перетаскивание, начинаем пинч
|
||||||
|
const pointersArray = Array.from(activePointers.current.values());
|
||||||
|
const p1 = pointersArray[0];
|
||||||
|
const p2 = pointersArray[1];
|
||||||
|
|
||||||
|
// Calculate initial values
|
||||||
|
const initialDistance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
|
||||||
|
const initialMidpoint = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
|
||||||
|
|
||||||
|
// Save initial gesture data in pinchStartData
|
||||||
|
pinchStartData.current = {
|
||||||
|
distance: initialDistance,
|
||||||
|
midpoint: initialMidpoint,
|
||||||
|
scale: scale,
|
||||||
|
position: { ...position },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep these for backward compatibility
|
||||||
|
setInitialPinchDistance(initialDistance);
|
||||||
|
setInitialPinchMidpoint(initialMidpoint);
|
||||||
|
|
||||||
|
setIsPinching(true);
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (e: FederatedPointerEvent) => {
|
||||||
|
if (!activePointers.current.has(e.pointerId)) return;
|
||||||
|
|
||||||
|
updateUserActivity();
|
||||||
|
activePointers.current.set(e.pointerId, { x: e.globalX, y: e.globalY });
|
||||||
|
|
||||||
|
if (
|
||||||
|
isPinching &&
|
||||||
|
activePointers.current.size === 2 &&
|
||||||
|
pinchStartData.current
|
||||||
|
) {
|
||||||
|
const pointersArray = Array.from(activePointers.current.values());
|
||||||
|
const p1 = pointersArray[0];
|
||||||
|
const p2 = pointersArray[1];
|
||||||
|
const currentDistance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
|
||||||
|
const currentMidpoint = {
|
||||||
|
x: (p1.x + p2.x) / 2,
|
||||||
|
y: (p1.y + p2.y) / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Calculate zoomFactor relative to the INITIAL distance
|
||||||
|
const zoomFactor = currentDistance / pinchStartData.current.distance;
|
||||||
|
|
||||||
|
// 2. Calculate new scale relative to the INITIAL scale
|
||||||
|
const targetScale = pinchStartData.current.scale * zoomFactor;
|
||||||
|
const newScale = getSmoothScale(targetScale, scale);
|
||||||
|
|
||||||
|
// 3. Calculate new position relative to the INITIAL position
|
||||||
|
// This is the standard formula for "zoom to point" (in our case, to the midpoint between fingers)
|
||||||
|
const newPosition = {
|
||||||
|
x:
|
||||||
|
pinchStartData.current.midpoint.x +
|
||||||
|
(pinchStartData.current.position.x -
|
||||||
|
pinchStartData.current.midpoint.x) *
|
||||||
|
(newScale / pinchStartData.current.scale),
|
||||||
|
y:
|
||||||
|
pinchStartData.current.midpoint.y +
|
||||||
|
(pinchStartData.current.position.y -
|
||||||
|
pinchStartData.current.midpoint.y) *
|
||||||
|
(newScale / pinchStartData.current.scale),
|
||||||
|
};
|
||||||
|
|
||||||
|
setPosition(newPosition);
|
||||||
|
setScale(newScale);
|
||||||
|
syncStateDebounced(newPosition, newScale);
|
||||||
|
|
||||||
|
// We do NOT update pinchStartData here - it remains fixed for the entire gesture
|
||||||
|
|
||||||
|
// Update these for backward compatibility, but they're not used for calculations
|
||||||
|
setInitialPinchDistance(currentDistance);
|
||||||
|
setInitialPinchMidpoint(currentMidpoint);
|
||||||
|
} else if (activePointers.current.size === 1) {
|
||||||
|
setIsPinching(false);
|
||||||
|
setInitialPinchDistance(null);
|
||||||
|
setInitialPinchMidpoint(null);
|
||||||
|
pinchStartData.current = null;
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
const newPosition = {
|
||||||
|
x: startPosition.x - startMousePosition.x + e.globalX,
|
||||||
|
y: startPosition.y - startMousePosition.y + e.globalY,
|
||||||
|
};
|
||||||
|
setPosition(newPosition);
|
||||||
|
syncStateDebounced(newPosition, scale);
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: FederatedPointerEvent) => {
|
||||||
|
handleUserActivity();
|
||||||
|
|
||||||
|
activePointers.current.delete(e.pointerId);
|
||||||
|
|
||||||
|
if (activePointers.current.size < 2) {
|
||||||
|
setIsPinching(false);
|
||||||
|
setInitialPinchDistance(null);
|
||||||
|
setInitialPinchMidpoint(null);
|
||||||
|
pinchStartData.current = null; // Clear pinch gesture data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activePointers.current.size === 0) {
|
||||||
|
setIsDragging(false);
|
||||||
|
} else if (activePointers.current.size === 1) {
|
||||||
|
// If one finger remains after pinch, start dragging from the new position
|
||||||
|
const remainingPointer = Array.from(activePointers.current.values())[0];
|
||||||
|
setStartPosition({ x: position.x, y: position.y });
|
||||||
|
setStartMousePosition({ x: remainingPointer.x, y: remainingPointer.y });
|
||||||
|
setIsDragging(true);
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (e: FederatedWheelEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleUserActivity(); // Используем новую функцию
|
||||||
|
|
||||||
|
const mouseX = e.globalX - position.x;
|
||||||
|
const mouseY = e.globalY - position.y;
|
||||||
|
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
|
||||||
|
// Используем плавное ограничение масштаба
|
||||||
|
const targetScale = scale * zoomFactor;
|
||||||
|
const newScale = getSmoothScale(targetScale, scale);
|
||||||
|
const actualZoomFactor = newScale / scale;
|
||||||
|
|
||||||
|
if (scale !== newScale && newScale >= scaleMin && newScale <= scaleMax) {
|
||||||
|
// Убираем из условия && newScale >= scaleMin && newScale <= scaleMax
|
||||||
|
const newPosition = {
|
||||||
|
x: position.x + mouseX * (1 - actualZoomFactor),
|
||||||
|
y: position.y + mouseY * (1 - actualZoomFactor),
|
||||||
|
};
|
||||||
|
setPosition(newPosition);
|
||||||
|
setScale(newScale);
|
||||||
|
// Используем дебаунсированную функцию для синхронизации стора после зума
|
||||||
|
syncStateDebounced(newPosition, newScale);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<pixiGraphics
|
||||||
|
draw={(g) => {
|
||||||
|
const canvas = applicationRef.app.canvas;
|
||||||
|
g.clear();
|
||||||
|
g.rect(0, 0, canvas?.width ?? 0, canvas?.height ?? 0);
|
||||||
|
g.fill(BACKGROUND_COLOR);
|
||||||
|
}}
|
||||||
|
eventMode="static"
|
||||||
|
interactive
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onGlobalPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerLeave={handlePointerUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
/>
|
||||||
|
<pixiContainer position={position} scale={scale}>
|
||||||
|
{children}
|
||||||
|
</pixiContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,22 +1,37 @@
|
|||||||
import {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useMemo,
|
useMemo,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Application, extend } from "@pixi/react";
|
||||||
|
import { Container, Graphics, Sprite, Text } from "pixi.js";
|
||||||
import { MapDataProvider, useMapData } from "./MapDataContext";
|
import { MapDataProvider, useMapData } from "./MapDataContext";
|
||||||
import { TransformProvider, useTransform } from "./transformContext";
|
import { TransformProvider, useTransform } from "./transformContext";
|
||||||
// @ts-ignore
|
import { InfiniteCanvas } from "./InfiniteCanvas";
|
||||||
|
import { TravelPath } from "./TravelPath";
|
||||||
|
import { Station } from "./Station";
|
||||||
|
import { SightsLayer } from "./Sight";
|
||||||
import Loader from "../Loader";
|
import Loader from "../Loader";
|
||||||
import { UP_SCALE } from "./Constants";
|
import {
|
||||||
|
BACKGROUND_COLOR,
|
||||||
|
BUS_COLOR,
|
||||||
|
STATION_OUTLINE_WIDTH,
|
||||||
|
STATION_RADIUS,
|
||||||
|
UP_SCALE,
|
||||||
|
} from "./Constants";
|
||||||
import "../../styles/MapLayer.css";
|
import "../../styles/MapLayer.css";
|
||||||
import { useCameraAnimationStore } from "../../stores";
|
import { useGeolocationStore, useCameraAnimationStore } from "../../stores";
|
||||||
import { coordinatesToLocal } from "./utils";
|
import { coordinatesToLocal } from "./utils";
|
||||||
|
import { TramIcon } from "./TramIcon";
|
||||||
import { SCALE_FACTOR } from "../../assets/Constants";
|
import { SCALE_FACTOR } from "../../assets/Constants";
|
||||||
import { apiStore } from "../../api/ApiStore/store";
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
import WebGLMap from "./WebGLMap";
|
import WebGLMap from "./WebGLMap";
|
||||||
|
|
||||||
|
extend({ Container, Graphics, Text, Sprite });
|
||||||
|
|
||||||
export function Map() {
|
export function Map() {
|
||||||
return (
|
return (
|
||||||
<MapDataProvider>
|
<MapDataProvider>
|
||||||
@@ -27,7 +42,7 @@ export function Map() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotatePoint(x: number, y: number, originX: number, originY: number, angle: number) {
|
function rotatePoint(x, y, originX, originY, angle) {
|
||||||
const cos = Math.cos(angle);
|
const cos = Math.cos(angle);
|
||||||
const sin = Math.sin(angle);
|
const sin = Math.sin(angle);
|
||||||
const dx = x - originX;
|
const dx = x - originX;
|
||||||
@@ -38,6 +53,8 @@ function rotatePoint(x: number, y: number, originX: number, originY: number, ang
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RouteMap = observer(() => {
|
const RouteMap = observer(() => {
|
||||||
|
const store = useGeolocationStore();
|
||||||
|
const { contextData } = store;
|
||||||
const {
|
const {
|
||||||
routeData,
|
routeData,
|
||||||
stationData,
|
stationData,
|
||||||
@@ -60,6 +77,7 @@ const RouteMap = observer(() => {
|
|||||||
scale,
|
scale,
|
||||||
} = useTransform();
|
} = useTransform();
|
||||||
const cameraAnimationStore = useCameraAnimationStore();
|
const cameraAnimationStore = useCameraAnimationStore();
|
||||||
|
const parentRef = useRef(null);
|
||||||
|
|
||||||
const [rotationAngle, setRotationAngle] = useState(0);
|
const [rotationAngle, setRotationAngle] = useState(0);
|
||||||
|
|
||||||
@@ -125,7 +143,7 @@ const RouteMap = observer(() => {
|
|||||||
const rotationOriginY = 0;
|
const rotationOriginY = 0;
|
||||||
|
|
||||||
const transformGeoToMapLocal = useCallback(
|
const transformGeoToMapLocal = useCallback(
|
||||||
(latitude: number, longitude: number) => {
|
(latitude, longitude) => {
|
||||||
if (centerLat === undefined || centerLon === undefined) {
|
if (centerLat === undefined || centerLon === undefined) {
|
||||||
return { x: 0, y: 0 };
|
return { x: 0, y: 0 };
|
||||||
}
|
}
|
||||||
@@ -221,6 +239,99 @@ const RouteMap = observer(() => {
|
|||||||
transformedStations,
|
transformedStations,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const drawActualBusPos = useCallback(
|
||||||
|
(g: Graphics) => {
|
||||||
|
g.clear();
|
||||||
|
if (transformedCurrentCoordinates) {
|
||||||
|
g.circle(
|
||||||
|
transformedCurrentCoordinates.x,
|
||||||
|
transformedCurrentCoordinates.y,
|
||||||
|
STATION_RADIUS / scale < 10
|
||||||
|
? 10
|
||||||
|
: STATION_RADIUS / scale > 20
|
||||||
|
? 20
|
||||||
|
: STATION_RADIUS / scale
|
||||||
|
);
|
||||||
|
g.fill({ color: BUS_COLOR });
|
||||||
|
g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[transformedCurrentCoordinates, scale]
|
||||||
|
);
|
||||||
|
|
||||||
|
const scaledPoints = useMemo(() => {
|
||||||
|
if (!routeData?.path) return [];
|
||||||
|
|
||||||
|
return routeData.path.map(([latitude, longitude]) => {
|
||||||
|
const { x, y } = transformGeoToMapLocal(latitude, longitude);
|
||||||
|
return rotatePoint(x, y, rotationOriginX, rotationOriginY, rotationAngle);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
routeData?.path,
|
||||||
|
transformGeoToMapLocal,
|
||||||
|
rotationOriginX,
|
||||||
|
rotationOriginY,
|
||||||
|
rotationAngle,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const transformedStationsEn = useMemo(() => {
|
||||||
|
if (!stationDataEn) return [];
|
||||||
|
|
||||||
|
return stationDataEn.map((station) => {
|
||||||
|
const { x, y } = transformGeoToMapLocal(
|
||||||
|
station.latitude,
|
||||||
|
station.longitude
|
||||||
|
);
|
||||||
|
const rotatedCoords = rotatePoint(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
rotationOriginX,
|
||||||
|
rotationOriginY,
|
||||||
|
rotationAngle
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...station,
|
||||||
|
longitude: rotatedCoords.x,
|
||||||
|
latitude: rotatedCoords.y,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
stationDataEn,
|
||||||
|
transformGeoToMapLocal,
|
||||||
|
rotationOriginX,
|
||||||
|
rotationOriginY,
|
||||||
|
rotationAngle,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const transformedStationsZh = useMemo(() => {
|
||||||
|
if (!stationDataZh) return [];
|
||||||
|
|
||||||
|
return stationDataZh.map((station) => {
|
||||||
|
const { x, y } = transformGeoToMapLocal(
|
||||||
|
station.latitude,
|
||||||
|
station.longitude
|
||||||
|
);
|
||||||
|
const rotatedCoords = rotatePoint(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
rotationOriginX,
|
||||||
|
rotationOriginY,
|
||||||
|
rotationAngle
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...station,
|
||||||
|
longitude: rotatedCoords.x,
|
||||||
|
latitude: rotatedCoords.y,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
stationDataZh,
|
||||||
|
transformGeoToMapLocal,
|
||||||
|
rotationOriginX,
|
||||||
|
rotationOriginY,
|
||||||
|
rotationAngle,
|
||||||
|
]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!routeData ||
|
!routeData ||
|
||||||
!stationData ||
|
!stationData ||
|
||||||
|
|||||||
395
src/client/src/components/map/Sight.tsx
Normal file
395
src/client/src/components/map/Sight.tsx
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
// SightsLayer.tsx
|
||||||
|
import React from "react";
|
||||||
|
import { Graphics, Assets, Texture, TextStyle } from "pixi.js";
|
||||||
|
import { useCallback, useEffect, useState, useMemo } from "react";
|
||||||
|
import { useTransform } from "./transformContext";
|
||||||
|
import { SightData } from "./types";
|
||||||
|
import sightIcon from "../../assets/images/sight.svg";
|
||||||
|
import { useGeolocationStore } from "../../stores"; // Импортируем useGeolocationStore
|
||||||
|
|
||||||
|
const BASE_ICON_SIZE = 30;
|
||||||
|
const CLUSTER_RADIUS_BASE = 10;
|
||||||
|
const CLUSTER_COLOR = 0x1a73e8;
|
||||||
|
|
||||||
|
type Cluster = {
|
||||||
|
id: string;
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
count: number;
|
||||||
|
sights: SightData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PointItem = { type: "point"; id: string; data: SightData };
|
||||||
|
type ClusterItem = { type: "cluster"; id: string; data: Cluster };
|
||||||
|
type ClusteredItem = PointItem | ClusterItem;
|
||||||
|
|
||||||
|
const getDistance = (
|
||||||
|
p1: { longitude: number; latitude: number },
|
||||||
|
p2: { longitude: number; latitude: number }
|
||||||
|
) => {
|
||||||
|
return Math.sqrt(
|
||||||
|
Math.pow(p1.longitude - p2.longitude, 2) +
|
||||||
|
Math.pow(p1.latitude - p2.latitude, 2)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDistanceFromPointToPath = (
|
||||||
|
point: { longitude: number; latitude: number },
|
||||||
|
pathPoints: { x: number; y: number }[]
|
||||||
|
): number => {
|
||||||
|
if (!pathPoints || pathPoints.length < 2) {
|
||||||
|
return Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < pathPoints.length - 1; i++) {
|
||||||
|
const p1 = pathPoints[i];
|
||||||
|
const p2 = pathPoints[i + 1];
|
||||||
|
|
||||||
|
const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
|
||||||
|
if (lineLengthSq === 0) continue;
|
||||||
|
|
||||||
|
const t =
|
||||||
|
((point.longitude - p1.x) * (p2.x - p1.x) +
|
||||||
|
(point.latitude - p1.y) * (p2.y - p1.y)) /
|
||||||
|
lineLengthSq;
|
||||||
|
const projectionT = Math.max(0, Math.min(1, t));
|
||||||
|
|
||||||
|
const projectedX = p1.x + projectionT * (p2.x - p1.x);
|
||||||
|
const projectedY = p1.y + projectionT * (p2.y - p1.y);
|
||||||
|
|
||||||
|
const dist = Math.sqrt(
|
||||||
|
(point.longitude - projectedX) ** 2 + (point.latitude - projectedY) ** 2
|
||||||
|
);
|
||||||
|
minDistance = Math.min(minDistance, dist);
|
||||||
|
}
|
||||||
|
return minDistance;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useSightClustering = (
|
||||||
|
sights: SightData[],
|
||||||
|
distanceThreshold: number,
|
||||||
|
pathPoints: { x: number; y: number }[]
|
||||||
|
): ClusteredItem[] => {
|
||||||
|
return useMemo(() => {
|
||||||
|
const unclusteredSights: (SightData & { visited?: boolean })[] = sights.map(
|
||||||
|
(s) => ({ ...s })
|
||||||
|
);
|
||||||
|
const clusteredResult: ClusteredItem[] = [];
|
||||||
|
|
||||||
|
for (const sight of unclusteredSights) {
|
||||||
|
if (sight.visited) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clusterSights: SightData[] = [];
|
||||||
|
const queue = [sight];
|
||||||
|
sight.visited = true;
|
||||||
|
|
||||||
|
while (queue.length > 0 && clusterSights.length < 4) {
|
||||||
|
const current = queue.shift()!;
|
||||||
|
clusterSights.push(current);
|
||||||
|
|
||||||
|
for (const potentialNeighbor of unclusteredSights) {
|
||||||
|
if (
|
||||||
|
!potentialNeighbor.visited &&
|
||||||
|
clusterSights.length < 4 &&
|
||||||
|
getDistance(current, potentialNeighbor) < distanceThreshold
|
||||||
|
) {
|
||||||
|
potentialNeighbor.visited = true;
|
||||||
|
queue.push(potentialNeighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clusterSights.length > 1) {
|
||||||
|
let furthestSight: SightData | null = null;
|
||||||
|
let maxDistanceToPath = -1;
|
||||||
|
|
||||||
|
for (const s of clusterSights) {
|
||||||
|
const dist = getDistanceFromPointToPath(s, pathPoints);
|
||||||
|
if (dist > maxDistanceToPath) {
|
||||||
|
maxDistanceToPath = dist;
|
||||||
|
furthestSight = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = clusterSights.length;
|
||||||
|
const longitude = furthestSight
|
||||||
|
? furthestSight.longitude
|
||||||
|
: clusterSights.reduce((sum, s) => sum + s.longitude, 0) / count;
|
||||||
|
const latitude = furthestSight
|
||||||
|
? furthestSight.latitude
|
||||||
|
: clusterSights.reduce((sum, s) => sum + s.latitude, 0) / count;
|
||||||
|
|
||||||
|
const id = `cluster-${clusterSights[0].id}`;
|
||||||
|
clusteredResult.push({
|
||||||
|
type: "cluster",
|
||||||
|
id,
|
||||||
|
data: { id, count, longitude, latitude, sights: clusterSights },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const singleSight = clusterSights[0];
|
||||||
|
clusteredResult.push({
|
||||||
|
type: "point",
|
||||||
|
id: String(singleSight.id),
|
||||||
|
data: singleSight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clusteredResult;
|
||||||
|
}, [sights, distanceThreshold, pathPoints]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Изменяем пропсы для SingleSight, чтобы он мог передавать ID при тапе
|
||||||
|
function SingleSight({
|
||||||
|
sight,
|
||||||
|
onSightClick,
|
||||||
|
}: {
|
||||||
|
readonly sight: SightData;
|
||||||
|
onSightClick: (sightId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { scale } = useTransform();
|
||||||
|
const [texture, setTexture] = useState<Texture>(Texture.EMPTY);
|
||||||
|
const store = useGeolocationStore();
|
||||||
|
const { setIsGovernorWidgetOpen } = store;
|
||||||
|
useEffect(() => {
|
||||||
|
Assets.load(sightIcon).then(setTexture).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerTap = useCallback(() => {
|
||||||
|
setIsGovernorWidgetOpen(false);
|
||||||
|
onSightClick(String(sight.id)); // Передаем ID выбранной достопримечательности
|
||||||
|
}, [sight.id, onSightClick]);
|
||||||
|
|
||||||
|
if (texture === Texture.EMPTY) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dynamicSize = BASE_ICON_SIZE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiSprite
|
||||||
|
texture={texture}
|
||||||
|
x={sight.longitude}
|
||||||
|
y={sight.latitude}
|
||||||
|
width={dynamicSize}
|
||||||
|
height={dynamicSize}
|
||||||
|
anchor={0.5}
|
||||||
|
eventMode="static"
|
||||||
|
onPointerTap={handlePointerTap}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем onSightSelectedInCluster в пропсы
|
||||||
|
function SightCluster({
|
||||||
|
cluster,
|
||||||
|
onClusterToggle,
|
||||||
|
isExpanded,
|
||||||
|
onSightSelectedInCluster,
|
||||||
|
}: {
|
||||||
|
readonly cluster: Cluster;
|
||||||
|
onClusterToggle: (clusterId: string | null) => void;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onSightSelectedInCluster: (sightId: string) => void;
|
||||||
|
}) {
|
||||||
|
const store = useGeolocationStore();
|
||||||
|
const { setIsGovernorWidgetOpen } = store;
|
||||||
|
const { scale } = useTransform();
|
||||||
|
const radius = CLUSTER_RADIUS_BASE;
|
||||||
|
const [texture, setTexture] = useState<Texture>(Texture.EMPTY);
|
||||||
|
const fontSize = 14;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Assets.load(sightIcon).then(setTexture).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clusterTextStyle = useMemo(
|
||||||
|
() =>
|
||||||
|
new TextStyle({
|
||||||
|
fill: "white",
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: "bold",
|
||||||
|
}),
|
||||||
|
[fontSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClusterTap = useCallback(() => {
|
||||||
|
onClusterToggle(isExpanded ? null : cluster.id);
|
||||||
|
}, [cluster.id, cluster.count, isExpanded, onClusterToggle]);
|
||||||
|
|
||||||
|
const handleSightSelect = useCallback(
|
||||||
|
(sightId: string) => {
|
||||||
|
setIsGovernorWidgetOpen(false);
|
||||||
|
onSightSelectedInCluster(sightId); // Передаем выбранный ID наверх
|
||||||
|
onClusterToggle(null); // Закрываем кластер после выбора достопримечательности
|
||||||
|
},
|
||||||
|
[onClusterToggle, onSightSelectedInCluster, setIsGovernorWidgetOpen]
|
||||||
|
); // Добавляем onSightSelectedInCluster в зависимости
|
||||||
|
|
||||||
|
const drawClusterGraphics = useCallback(
|
||||||
|
(g: Graphics) => {
|
||||||
|
g.clear();
|
||||||
|
g.beginFill(0x896f58);
|
||||||
|
g.drawCircle(0, 0, radius);
|
||||||
|
g.endFill();
|
||||||
|
},
|
||||||
|
[radius]
|
||||||
|
);
|
||||||
|
|
||||||
|
const drawExpandedClusterBackground = useCallback((g: Graphics) => {
|
||||||
|
g.clear();
|
||||||
|
const expandedRadius = BASE_ICON_SIZE * 2;
|
||||||
|
g.beginFill(0x896f58, 0.3);
|
||||||
|
g.lineStyle(2, 0x896f58, 1);
|
||||||
|
g.drawCircle(0, 0, expandedRadius);
|
||||||
|
g.endFill();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBackgroundTap = useCallback(() => {
|
||||||
|
onClusterToggle(null); // Закрываем кластер при клике по фону
|
||||||
|
}, [cluster.id, onClusterToggle]);
|
||||||
|
|
||||||
|
if (texture === Texture.EMPTY) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dynamicSize = BASE_ICON_SIZE;
|
||||||
|
const offsetX = dynamicSize / 2;
|
||||||
|
const offsetY = -dynamicSize / 2;
|
||||||
|
|
||||||
|
const getPositionForSight = (index: number, total: number) => {
|
||||||
|
const angle = (index / total) * Math.PI * 2;
|
||||||
|
const distance = BASE_ICON_SIZE * 1.2;
|
||||||
|
return {
|
||||||
|
x: distance * Math.cos(angle),
|
||||||
|
y: distance * Math.sin(angle),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiContainer x={cluster.longitude} y={cluster.latitude}>
|
||||||
|
{isExpanded && (
|
||||||
|
<pixiGraphics
|
||||||
|
draw={drawExpandedClusterBackground}
|
||||||
|
eventMode="static"
|
||||||
|
onPointerTap={handleBackgroundTap}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isExpanded ? (
|
||||||
|
<>
|
||||||
|
<pixiSprite
|
||||||
|
texture={texture}
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
width={dynamicSize}
|
||||||
|
height={dynamicSize}
|
||||||
|
anchor={0.5}
|
||||||
|
eventMode="static"
|
||||||
|
onPointerTap={handleClusterTap}
|
||||||
|
/>
|
||||||
|
<pixiGraphics draw={drawClusterGraphics} x={offsetX} y={offsetY} />
|
||||||
|
<pixiText
|
||||||
|
text={String(cluster.count)}
|
||||||
|
x={offsetX}
|
||||||
|
y={offsetY}
|
||||||
|
anchor={0.5}
|
||||||
|
style={clusterTextStyle}
|
||||||
|
resolution={4}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{cluster.sights.map((sight, index) => {
|
||||||
|
const pos = getPositionForSight(index, cluster.sights.length);
|
||||||
|
return (
|
||||||
|
<pixiSprite
|
||||||
|
key={sight.id}
|
||||||
|
texture={texture}
|
||||||
|
x={pos.x}
|
||||||
|
y={pos.y}
|
||||||
|
width={dynamicSize}
|
||||||
|
height={dynamicSize}
|
||||||
|
anchor={0.5}
|
||||||
|
eventMode="static"
|
||||||
|
onPointerTap={() => handleSightSelect(String(sight.id))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</pixiContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SightsLayerProps {
|
||||||
|
sights: SightData[];
|
||||||
|
pathPoints: { x: number; y: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SightsLayer({
|
||||||
|
sights,
|
||||||
|
pathPoints,
|
||||||
|
}: Readonly<SightsLayerProps>) {
|
||||||
|
const { scale } = useTransform();
|
||||||
|
const distanceThreshold = BASE_ICON_SIZE * 3;
|
||||||
|
|
||||||
|
const store = useGeolocationStore(); // Получаем доступ к MobX хранилищу
|
||||||
|
const {
|
||||||
|
setSelectedSightId,
|
||||||
|
setIsManualSelection,
|
||||||
|
setIsRightWidgetSelectorOpen,
|
||||||
|
} = store; // Получаем нужные экшены
|
||||||
|
|
||||||
|
const items = useSightClustering(sights, distanceThreshold, pathPoints);
|
||||||
|
|
||||||
|
const [activeClusterId, setActiveClusterId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleClusterToggle = useCallback((clusterId: string | null) => {
|
||||||
|
setActiveClusterId(clusterId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSightSelected = useCallback(
|
||||||
|
(sightId: string) => {
|
||||||
|
setSelectedSightId(sightId);
|
||||||
|
setIsManualSelection(true);
|
||||||
|
setIsRightWidgetSelectorOpen(false); // Закрываем селектор правого виджета при клике по достопримечательности
|
||||||
|
// Закрываем виджет губернатора при выборе достопримечательности
|
||||||
|
store.closeGovernorModal();
|
||||||
|
},
|
||||||
|
[
|
||||||
|
setSelectedSightId,
|
||||||
|
setIsManualSelection,
|
||||||
|
setIsRightWidgetSelectorOpen,
|
||||||
|
store,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.map((item) => {
|
||||||
|
if (item.type === "cluster") {
|
||||||
|
return (
|
||||||
|
<SightCluster
|
||||||
|
key={item.id}
|
||||||
|
cluster={item.data}
|
||||||
|
onClusterToggle={handleClusterToggle}
|
||||||
|
isExpanded={activeClusterId === item.id}
|
||||||
|
onSightSelectedInCluster={handleSightSelected}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<SingleSight
|
||||||
|
key={item.id}
|
||||||
|
sight={item.data}
|
||||||
|
onSightClick={handleSightSelected}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
src/client/src/components/map/Station.tsx
Normal file
174
src/client/src/components/map/Station.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { Graphics } from "pixi.js";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
BACKGROUND_COLOR,
|
||||||
|
PATH_COLOR,
|
||||||
|
STATION_RADIUS,
|
||||||
|
STATION_OUTLINE_WIDTH,
|
||||||
|
UNPASSED_STATION_COLOR,
|
||||||
|
} from "./Constants";
|
||||||
|
import { StationData } from "./types";
|
||||||
|
import { useTransform } from "./transformContext";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useGeolocationStore } from "../../stores/hooks/useGeolocationStore";
|
||||||
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
|
|
||||||
|
interface StationProps {
|
||||||
|
station: StationData;
|
||||||
|
stationEn?: StationData | null;
|
||||||
|
stationZh?: StationData | null;
|
||||||
|
isPassed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_FONT_SIZE = 16;
|
||||||
|
const POINT_LABEL_FONT_SIZE = 13;
|
||||||
|
const DEFAULT_LABEL_OFFSET_X = 25;
|
||||||
|
const DEFAULT_LABEL_OFFSET_Y = 0;
|
||||||
|
|
||||||
|
const getAnchorFromOffset = (
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number
|
||||||
|
): { x: number; y: number } => {
|
||||||
|
if (offsetX === 0 && offsetY === 0) {
|
||||||
|
return { x: 0, y: 0.5 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = Math.hypot(offsetX, offsetY);
|
||||||
|
const nx = offsetX / length;
|
||||||
|
const ny = offsetY / length;
|
||||||
|
|
||||||
|
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Station = observer(
|
||||||
|
({ station, stationEn, stationZh, isPassed }: Readonly<StationProps>) => {
|
||||||
|
const { scale } = useTransform();
|
||||||
|
const { context } = apiStore;
|
||||||
|
|
||||||
|
const { selectedLanguage } = useGeolocationStore();
|
||||||
|
|
||||||
|
const ZOOM_THRESHOLD_FOR_HIGH_RESOLUTION = 2; // Порог масштаба (в 2 раза)
|
||||||
|
const HIGH_RESOLUTION_VALUE = 4;
|
||||||
|
const LOW_RESOLUTION_VALUE = 2;
|
||||||
|
|
||||||
|
const dynamicResolution =
|
||||||
|
scale > ZOOM_THRESHOLD_FOR_HIGH_RESOLUTION
|
||||||
|
? HIGH_RESOLUTION_VALUE
|
||||||
|
: LOW_RESOLUTION_VALUE;
|
||||||
|
|
||||||
|
const draw = useCallback(
|
||||||
|
(g: Graphics) => {
|
||||||
|
g.clear();
|
||||||
|
|
||||||
|
// Проверяем, является ли станция начальной или конечной
|
||||||
|
const isTerminalStation =
|
||||||
|
context &&
|
||||||
|
(station.id.toString() === (context as any)?.startStopId ||
|
||||||
|
station.id.toString() === (context as any)?.endStopId);
|
||||||
|
|
||||||
|
// Определяем радиус станции
|
||||||
|
const stationRadius = isTerminalStation
|
||||||
|
? STATION_RADIUS * 1.5
|
||||||
|
: STATION_RADIUS;
|
||||||
|
|
||||||
|
// Рисуем основной круг станции
|
||||||
|
g.circle(station.longitude, station.latitude, stationRadius);
|
||||||
|
g.fill({ color: isPassed ? PATH_COLOR : UNPASSED_STATION_COLOR });
|
||||||
|
g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH });
|
||||||
|
|
||||||
|
// Если это терминальная станция, рисуем дополнительный круг по центру
|
||||||
|
if (isTerminalStation) {
|
||||||
|
const centerCircleRadius = stationRadius * 0.5; // 50% от радиуса станции
|
||||||
|
g.circle(station.longitude, station.latitude, centerCircleRadius);
|
||||||
|
g.fill({ color: BACKGROUND_COLOR });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[station.latitude, station.longitude, station.id, context, isPassed]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dynamicFontSize = BASE_FONT_SIZE;
|
||||||
|
const dynamicPointLabelFontSize = POINT_LABEL_FONT_SIZE;
|
||||||
|
|
||||||
|
// Определяем фактические смещения. Если station.offset_x и station.offset_y оба равны 0,
|
||||||
|
// используем дефолтный отступ для обеспечения видимости.
|
||||||
|
const labelOffsetX =
|
||||||
|
station.offset_x === 0 && station.offset_y === 0
|
||||||
|
? DEFAULT_LABEL_OFFSET_X
|
||||||
|
: station.offset_x / 3;
|
||||||
|
const labelOffsetY =
|
||||||
|
station.offset_x === 0 && station.offset_y === 0
|
||||||
|
? DEFAULT_LABEL_OFFSET_Y
|
||||||
|
: station.offset_y / 3;
|
||||||
|
|
||||||
|
// Вычисляем позицию текстового блока. Это прямые координаты карты.
|
||||||
|
const textBlockPositionX = station.longitude + labelOffsetX / 3;
|
||||||
|
const textBlockPositionY = station.latitude + labelOffsetY;
|
||||||
|
|
||||||
|
// Используем useMemo для запоминания dynamicAnchor на основе *фактически используемого* отступа.
|
||||||
|
const dynamicAnchor = useMemo(
|
||||||
|
() => getAnchorFromOffset(labelOffsetX, labelOffsetY),
|
||||||
|
[labelOffsetX, labelOffsetY]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiContainer>
|
||||||
|
<pixiGraphics draw={draw} zIndex={0} />
|
||||||
|
|
||||||
|
<pixiText
|
||||||
|
anchor={dynamicAnchor} // Используем динамический якорь
|
||||||
|
text={station.name}
|
||||||
|
position={{
|
||||||
|
x: textBlockPositionX,
|
||||||
|
y: textBlockPositionY,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: dynamicFontSize,
|
||||||
|
fontFamily: "Roboto",
|
||||||
|
fontWeight: "bold",
|
||||||
|
fill: "#fff",
|
||||||
|
}}
|
||||||
|
resolution={dynamicResolution}
|
||||||
|
zIndex={100}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(selectedLanguage === "en" || selectedLanguage === "ru") && (
|
||||||
|
<pixiText
|
||||||
|
anchor={dynamicAnchor} // Используем динамический якорь
|
||||||
|
text={stationEn?.name}
|
||||||
|
position={{
|
||||||
|
x: textBlockPositionX,
|
||||||
|
y: textBlockPositionY + 14, // Это смещение второй надписи относительно первой
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: dynamicPointLabelFontSize,
|
||||||
|
fontFamily: "Roboto",
|
||||||
|
fontWeight: "normal",
|
||||||
|
fill: "#CBCBCB",
|
||||||
|
}}
|
||||||
|
resolution={dynamicResolution}
|
||||||
|
zIndex={100}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedLanguage === "zh" && (
|
||||||
|
<pixiText
|
||||||
|
anchor={dynamicAnchor} // Используем динамический якорь
|
||||||
|
text={stationZh?.name}
|
||||||
|
position={{
|
||||||
|
x: textBlockPositionX,
|
||||||
|
y: textBlockPositionY + 14, // Это смещение второй надписи относительно первой
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: dynamicPointLabelFontSize,
|
||||||
|
fontFamily: "Roboto",
|
||||||
|
fontWeight: "normal",
|
||||||
|
fill: "#CBCBCB",
|
||||||
|
}}
|
||||||
|
resolution={dynamicResolution}
|
||||||
|
zIndex={100}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</pixiContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
378
src/client/src/components/map/TramIcon.tsx
Normal file
378
src/client/src/components/map/TramIcon.tsx
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Texture, Assets, Graphics } from "pixi.js";
|
||||||
|
import { useEffect, useState, useMemo, useRef } from "react";
|
||||||
|
import { useTransform } from "./transformContext";
|
||||||
|
import { lerp, lerpAngle } from "../../utils/animationUtils";
|
||||||
|
|
||||||
|
const basePath = new URL(
|
||||||
|
"../../assets/tramPosition/Tram Base.svg",
|
||||||
|
import.meta.url
|
||||||
|
).href;
|
||||||
|
const tramPath = new URL("../../assets/tramPosition/Tram.svg", import.meta.url)
|
||||||
|
.href;
|
||||||
|
|
||||||
|
// Константы анимации (как в HTML файле)
|
||||||
|
const ANIMATION_DURATION = 1200; // 1.2 секунды
|
||||||
|
const LERP_SPEED = 0.1; // Скорость интерполяции (10% каждый кадр)
|
||||||
|
|
||||||
|
// Функция для проверки расстояния до ближайшей точки маршрута
|
||||||
|
const getDistanceToPath = (
|
||||||
|
point: { x: number; y: number },
|
||||||
|
pathPoints: { x: number; y: number }[]
|
||||||
|
) => {
|
||||||
|
if (!pathPoints || pathPoints.length < 2) return Infinity;
|
||||||
|
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < pathPoints.length - 1; i++) {
|
||||||
|
const p1 = pathPoints[i];
|
||||||
|
const p2 = pathPoints[i + 1];
|
||||||
|
|
||||||
|
const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
|
||||||
|
if (lineLengthSq === 0) continue;
|
||||||
|
|
||||||
|
const t =
|
||||||
|
((point.x - p1.x) * (p2.x - p1.x) + (point.y - p1.y) * (p2.y - p1.y)) /
|
||||||
|
lineLengthSq;
|
||||||
|
const projectionT = Math.max(0, Math.min(1, t));
|
||||||
|
|
||||||
|
const projectedX = p1.x + projectionT * (p2.x - p1.x);
|
||||||
|
const projectedY = p1.y + projectionT * (p2.y - p1.y);
|
||||||
|
|
||||||
|
const dist = Math.sqrt(
|
||||||
|
(point.x - projectedX) ** 2 + (point.y - projectedY) ** 2
|
||||||
|
);
|
||||||
|
if (dist < minDistance) {
|
||||||
|
minDistance = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return minDistance;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для проверки расстояния до пройденной части маршрута
|
||||||
|
const getDistanceToPassedPath = (
|
||||||
|
point: { x: number; y: number },
|
||||||
|
pathPoints: { x: number; y: number }[],
|
||||||
|
passedSegmentIndex: number
|
||||||
|
) => {
|
||||||
|
if (!pathPoints || pathPoints.length < 2 || passedSegmentIndex < 0)
|
||||||
|
return Infinity;
|
||||||
|
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
// Проверяем только пройденную часть (до passedSegmentIndex включительно)
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i <= Math.min(passedSegmentIndex, pathPoints.length - 2);
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
const p1 = pathPoints[i];
|
||||||
|
const p2 = pathPoints[i + 1];
|
||||||
|
|
||||||
|
const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
|
||||||
|
if (lineLengthSq === 0) continue;
|
||||||
|
|
||||||
|
const t =
|
||||||
|
((point.x - p1.x) * (p2.x - p1.x) + (point.y - p1.y) * (p2.y - p1.y)) /
|
||||||
|
lineLengthSq;
|
||||||
|
const projectionT = Math.max(0, Math.min(1, t));
|
||||||
|
|
||||||
|
const projectedX = p1.x + projectionT * (p2.x - p1.x);
|
||||||
|
const projectedY = p1.y + projectionT * (p2.y - p1.y);
|
||||||
|
|
||||||
|
const dist = Math.sqrt(
|
||||||
|
(point.x - projectedX) ** 2 + (point.y - projectedY) ** 2
|
||||||
|
);
|
||||||
|
if (dist < minDistance) {
|
||||||
|
minDistance = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return minDistance;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для проверки расстояния до ближайшей станции
|
||||||
|
const getDistanceToStations = (
|
||||||
|
point: { x: number; y: number },
|
||||||
|
stations: {
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
offset_x?: number;
|
||||||
|
offset_y?: number;
|
||||||
|
}[],
|
||||||
|
debug: boolean = false
|
||||||
|
) => {
|
||||||
|
if (!stations || stations.length === 0) {
|
||||||
|
return Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
for (const station of stations) {
|
||||||
|
// Рассчитываем позицию текста станции с учетом смещений (как в Station.tsx)
|
||||||
|
const DEFAULT_LABEL_OFFSET_X = 25;
|
||||||
|
const DEFAULT_LABEL_OFFSET_Y = 0;
|
||||||
|
|
||||||
|
const labelOffsetX =
|
||||||
|
station.offset_x === 0 && station.offset_y === 0
|
||||||
|
? DEFAULT_LABEL_OFFSET_X
|
||||||
|
: (station.offset_x || 0) / 3;
|
||||||
|
const labelOffsetY =
|
||||||
|
station.offset_x === 0 && station.offset_y === 0
|
||||||
|
? DEFAULT_LABEL_OFFSET_Y
|
||||||
|
: (station.offset_y || 0) / 3;
|
||||||
|
|
||||||
|
const textBlockPositionX = station.longitude + labelOffsetX;
|
||||||
|
const textBlockPositionY = station.latitude + labelOffsetY;
|
||||||
|
|
||||||
|
// Вычисляем расстояние до позиции текста станции (не до центра станции)
|
||||||
|
const dist = Math.sqrt(
|
||||||
|
(point.x - textBlockPositionX) ** 2 + (point.y - textBlockPositionY) ** 2
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dist < minDistance) {
|
||||||
|
minDistance = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return minDistance;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Функция для поиска оптимального угла поворота метки относительно трамвая
|
||||||
|
const findOptimalAngle = (
|
||||||
|
tramX: number,
|
||||||
|
tramY: number,
|
||||||
|
pathPoints: { x: number; y: number }[],
|
||||||
|
stations: {
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
offset_x?: number;
|
||||||
|
offset_y?: number;
|
||||||
|
}[],
|
||||||
|
passedSegmentIndex: number,
|
||||||
|
scale: number
|
||||||
|
) => {
|
||||||
|
const testRadiusInMapCoords = 100 / scale; // Радиус в координатах карты
|
||||||
|
const minSafeDistanceToPath = 60; // Минимальное безопасное расстояние до пути в пикселях
|
||||||
|
const minSafeDistanceToPassedPath = 60; // Минимальное безопасное расстояние до пройденной части в пикселях
|
||||||
|
const minSafeDistanceToStation = 50; // Минимальное безопасное расстояние до станции в пикселях
|
||||||
|
|
||||||
|
let bestAngle = 0;
|
||||||
|
let bestScore = Infinity; // Ищем минимальный вес
|
||||||
|
const segmentWeights: { angle: number; weight: number; distances: any }[] =
|
||||||
|
[];
|
||||||
|
|
||||||
|
// Проверяем 12 углов (каждые 30 градусов)
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const testAngle = (i * Math.PI * 2) / 12;
|
||||||
|
const testX = tramX + Math.cos(testAngle) * testRadiusInMapCoords;
|
||||||
|
const testY = tramY + Math.sin(testAngle) * testRadiusInMapCoords;
|
||||||
|
|
||||||
|
const distanceToPath =
|
||||||
|
getDistanceToPath({ x: testX, y: testY }, pathPoints) * scale; // В пикселях
|
||||||
|
const distanceToPassedPath =
|
||||||
|
getDistanceToPassedPath(
|
||||||
|
{ x: testX, y: testY },
|
||||||
|
pathPoints,
|
||||||
|
passedSegmentIndex
|
||||||
|
) * scale; // В пикселях
|
||||||
|
const distanceToStation =
|
||||||
|
getDistanceToStations({ x: testX, y: testY }, stations, false) * scale; // В пикселях с отладкой
|
||||||
|
|
||||||
|
// Вычисляем вес для этого угла (чем меньше расстояние, тем больше вес)
|
||||||
|
let weight = 0;
|
||||||
|
|
||||||
|
// Путь - вес 100
|
||||||
|
if (distanceToPath < minSafeDistanceToPath) {
|
||||||
|
weight += 100 * (1 - distanceToPath / minSafeDistanceToPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текст прошедшей станции - вес 10
|
||||||
|
if (distanceToPassedPath < minSafeDistanceToPassedPath) {
|
||||||
|
weight += 10 * (1 - distanceToPassedPath / minSafeDistanceToPassedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текст следующей станции - вес 1000
|
||||||
|
if (distanceToStation < minSafeDistanceToStation) {
|
||||||
|
weight += 1000 * (1 - distanceToStation / minSafeDistanceToStation);
|
||||||
|
}
|
||||||
|
|
||||||
|
segmentWeights.push({
|
||||||
|
angle: testAngle,
|
||||||
|
weight: Math.round(weight * 10) / 10, // Округляем для читаемости
|
||||||
|
distances: {
|
||||||
|
path: Math.round(distanceToPath),
|
||||||
|
passedPath: Math.round(distanceToPassedPath),
|
||||||
|
station: Math.round(distanceToStation),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (weight < bestScore) {
|
||||||
|
bestScore = weight;
|
||||||
|
bestAngle = testAngle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если несколько сегментов имеют одинаковый минимальный вес,
|
||||||
|
// выбираем тот, который максимально удален от препятствий
|
||||||
|
const minWeightSegments = segmentWeights.filter(
|
||||||
|
(s) => s.weight === bestScore
|
||||||
|
);
|
||||||
|
if (minWeightSegments.length > 1) {
|
||||||
|
// Из сегментов с минимальным весом выбираем тот, который максимально удален от препятствий
|
||||||
|
let bestDistanceSum = -1;
|
||||||
|
for (const segment of minWeightSegments) {
|
||||||
|
const distanceSum =
|
||||||
|
segment.distances.path +
|
||||||
|
segment.distances.passedPath +
|
||||||
|
segment.distances.station;
|
||||||
|
if (distanceSum > bestDistanceSum) {
|
||||||
|
bestDistanceSum = distanceSum;
|
||||||
|
bestAngle = segment.angle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bestAngle, segmentWeights };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TramIcon({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
angle,
|
||||||
|
pathPoints = [],
|
||||||
|
stations = [],
|
||||||
|
passedSegmentIndex = -1,
|
||||||
|
}: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
angle: number;
|
||||||
|
pathPoints?: { x: number; y: number }[];
|
||||||
|
stations?: {
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
offset_x?: number;
|
||||||
|
offset_y?: number;
|
||||||
|
}[];
|
||||||
|
passedSegmentIndex?: number;
|
||||||
|
}) {
|
||||||
|
const { scale } = useTransform();
|
||||||
|
|
||||||
|
// Находим оптимальный угол поворота метки относительно трамвая
|
||||||
|
const optimalAngle = useMemo(
|
||||||
|
() =>
|
||||||
|
findOptimalAngle(x, y, pathPoints, stations, passedSegmentIndex, scale)
|
||||||
|
.bestAngle,
|
||||||
|
[x, y, pathPoints, stations, passedSegmentIndex, scale]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Состояние для плавной анимации позиции и углов (как в HTML файле)
|
||||||
|
const [smoothPosition, setSmoothPosition] = useState({ x, y });
|
||||||
|
const [smoothOptimalAngle, setSmoothOptimalAngle] = useState(optimalAngle);
|
||||||
|
const [smoothTramAngle, setSmoothTramAngle] = useState(angle);
|
||||||
|
const animationRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
// Плавная анимация позиции и углов (логика из HTML файла)
|
||||||
|
useEffect(() => {
|
||||||
|
const animate = () => {
|
||||||
|
// Анимируем позицию (как в HTML файле)
|
||||||
|
setSmoothPosition((prev) => {
|
||||||
|
const newX = lerp(prev.x, x, LERP_SPEED);
|
||||||
|
const newY = lerp(prev.y, y, LERP_SPEED);
|
||||||
|
return {
|
||||||
|
x: Math.abs(newX - x) < 0.1 ? x : newX,
|
||||||
|
y: Math.abs(newY - y) < 0.1 ? y : newY,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Анимируем оптимальный угол
|
||||||
|
setSmoothOptimalAngle((prev) => {
|
||||||
|
const newAngle = lerpAngle(prev, optimalAngle, LERP_SPEED);
|
||||||
|
return Math.abs(newAngle - optimalAngle) < 0.01
|
||||||
|
? optimalAngle
|
||||||
|
: newAngle;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Анимируем угол трамвая
|
||||||
|
setSmoothTramAngle((prev) => {
|
||||||
|
const newAngle = lerpAngle(prev, angle, LERP_SPEED);
|
||||||
|
return Math.abs(newAngle - angle) < 0.01 ? angle : newAngle;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Продолжаем анимацию, если что-то еще не достигло цели
|
||||||
|
if (
|
||||||
|
Math.abs(smoothPosition.x - x) > 0.1 ||
|
||||||
|
Math.abs(smoothPosition.y - y) > 0.1 ||
|
||||||
|
Math.abs(smoothOptimalAngle - optimalAngle) > 0.01 ||
|
||||||
|
Math.abs(smoothTramAngle - angle) > 0.01
|
||||||
|
) {
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationRef.current !== undefined) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
optimalAngle,
|
||||||
|
angle,
|
||||||
|
smoothPosition,
|
||||||
|
smoothOptimalAngle,
|
||||||
|
smoothTramAngle,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Обычные размеры без увеличения
|
||||||
|
const backgroundWidth = 111 / scale;
|
||||||
|
const backgroundHeight = 82 / scale;
|
||||||
|
const tramWidth = 31 / scale;
|
||||||
|
const tramHeight = 52 / scale;
|
||||||
|
|
||||||
|
const [baseTexture, setBaseTexture] = useState<Texture | null>(null);
|
||||||
|
const [tramTexture, setTramTexture] = useState<Texture | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Assets.load(basePath).then(setBaseTexture).catch(console.error);
|
||||||
|
Assets.load(tramPath).then(setTramTexture).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!baseTexture || !tramTexture) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiContainer x={smoothPosition.x} y={smoothPosition.y}>
|
||||||
|
{/* вращающийся контейнер с плавным оптимальным углом */}
|
||||||
|
<pixiContainer rotation={smoothOptimalAngle + Math.PI}>
|
||||||
|
<pixiSprite
|
||||||
|
texture={baseTexture}
|
||||||
|
anchor={{ x: 1, y: 0.5 }}
|
||||||
|
width={backgroundWidth > 53.7 ? backgroundWidth : 53.7}
|
||||||
|
height={backgroundHeight > 39.68 ? backgroundHeight : 39.68}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* контейнер иконки трамвая с плавным поворотом направления движения */}
|
||||||
|
<pixiContainer
|
||||||
|
x={
|
||||||
|
backgroundWidth > 53.7
|
||||||
|
? -backgroundWidth / 1.42
|
||||||
|
: -backgroundWidth / 0.98
|
||||||
|
}
|
||||||
|
y={0}
|
||||||
|
>
|
||||||
|
<pixiSprite
|
||||||
|
texture={tramTexture}
|
||||||
|
anchor={0.5}
|
||||||
|
rotation={-smoothOptimalAngle - Math.PI}
|
||||||
|
width={tramWidth}
|
||||||
|
height={tramHeight}
|
||||||
|
/>
|
||||||
|
</pixiContainer>
|
||||||
|
</pixiContainer>
|
||||||
|
</pixiContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState, useRef } from "react";
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
import { lerpAngle } from "../../utils/animationUtils";
|
import { lerp, lerpAngle } from "../../utils/animationUtils";
|
||||||
import tramBase from "../../assets/tramPosition/Tram Base.svg";
|
import tramBase from "../../assets/tramPosition/Tram Base.svg";
|
||||||
import tramSvg from "../../assets/tramPosition/Tram_Second.svg";
|
import tramSvg from "../../assets/tramPosition/Tram_Second.svg";
|
||||||
import { getMediaUrl } from "../../api/apiConfig";
|
import { getMediaUrl } from "../../api/apiConfig";
|
||||||
|
|||||||
92
src/client/src/components/map/TravelPath.tsx
Normal file
92
src/client/src/components/map/TravelPath.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Graphics } from "pixi.js";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { PATH_COLOR, PATH_WIDTH, UNPASSED_STATION_COLOR } from "./Constants";
|
||||||
|
|
||||||
|
interface TravelPathProps {
|
||||||
|
points: { x: number; y: number }[];
|
||||||
|
busCoordinates?: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PASSED_PATH_COLOR = PATH_COLOR;
|
||||||
|
const UNPASSED_PATH_COLOR = UNPASSED_STATION_COLOR;
|
||||||
|
|
||||||
|
export function TravelPath({
|
||||||
|
points,
|
||||||
|
busCoordinates,
|
||||||
|
}: Readonly<TravelPathProps>) {
|
||||||
|
const draw = useCallback(
|
||||||
|
(g: Graphics) => {
|
||||||
|
g.clear();
|
||||||
|
|
||||||
|
if (points.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
g.moveTo(points[0].x, points[0].y);
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
g.lineTo(points[i].x, points[i].y);
|
||||||
|
}
|
||||||
|
g.stroke({
|
||||||
|
color: UNPASSED_PATH_COLOR,
|
||||||
|
width: PATH_WIDTH,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!busCoordinates) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let minDistance = Infinity;
|
||||||
|
let closestSegmentIndex = -1;
|
||||||
|
let busSegmentStartPoint: { x: number; y: number } | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const p1 = points[i];
|
||||||
|
const p2 = points[i + 1];
|
||||||
|
|
||||||
|
const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
|
||||||
|
if (lineLengthSq === 0) continue;
|
||||||
|
|
||||||
|
const t =
|
||||||
|
((busCoordinates.x - p1.x) * (p2.x - p1.x) +
|
||||||
|
(busCoordinates.y - p1.y) * (p2.y - p1.y)) /
|
||||||
|
lineLengthSq;
|
||||||
|
const projectionT = Math.max(0, Math.min(1, t));
|
||||||
|
|
||||||
|
const projectedX = p1.x + projectionT * (p2.x - p1.x);
|
||||||
|
const projectedY = p1.y + projectionT * (p2.y - p1.y);
|
||||||
|
|
||||||
|
const dist = Math.sqrt(
|
||||||
|
(busCoordinates.x - projectedX) ** 2 +
|
||||||
|
(busCoordinates.y - projectedY) ** 2
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dist < minDistance) {
|
||||||
|
minDistance = dist;
|
||||||
|
closestSegmentIndex = i;
|
||||||
|
busSegmentStartPoint = { x: projectedX, y: projectedY };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closestSegmentIndex !== -1 && busSegmentStartPoint) {
|
||||||
|
g.moveTo(points[0].x, points[0].y);
|
||||||
|
for (let i = 1; i <= closestSegmentIndex; i++) {
|
||||||
|
g.lineTo(points[i].x, points[i].y);
|
||||||
|
}
|
||||||
|
g.lineTo(busSegmentStartPoint.x, busSegmentStartPoint.y);
|
||||||
|
|
||||||
|
g.stroke({
|
||||||
|
color: PASSED_PATH_COLOR,
|
||||||
|
width: PATH_WIDTH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[points, busCoordinates]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (points.length === 0) {
|
||||||
|
console.error("points is empty");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <pixiGraphics draw={draw} />;
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ import {
|
|||||||
UNPASSED_STATION_COLOR,
|
UNPASSED_STATION_COLOR,
|
||||||
BUS_COLOR,
|
BUS_COLOR,
|
||||||
BASE_ICON_SIZE,
|
BASE_ICON_SIZE,
|
||||||
|
CLUSTER_RADIUS_BASE,
|
||||||
|
CLUSTER_COLOR,
|
||||||
|
ACTIVE_STATION_COLOR,
|
||||||
} from "./Constants";
|
} from "./Constants";
|
||||||
import { SCALE_FACTOR } from "../../assets/Constants";
|
import { SCALE_FACTOR } from "../../assets/Constants";
|
||||||
import { apiStore } from "../../api/ApiStore/store";
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
@@ -37,7 +40,7 @@ const YELLOW_ICON_FILTER =
|
|||||||
const clamp = (value: number, min: number, max: number) =>
|
const clamp = (value: number, min: number, max: number) =>
|
||||||
Math.min(max, Math.max(min, value));
|
Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
const debugWebglLog = (..._args: unknown[]) => {
|
const debugWebglLog = (...args: unknown[]) => {
|
||||||
if (!DEBUG_WEBGL_ROUTE_MAP) return;
|
if (!DEBUG_WEBGL_ROUTE_MAP) return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,19 +158,6 @@ const useSightClustering = (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasCustomIcon =
|
|
||||||
sight.is_default_icon === false && !isMediaIdEmpty(sight.icon ?? null);
|
|
||||||
|
|
||||||
if (hasCustomIcon) {
|
|
||||||
sight.visited = true;
|
|
||||||
clusteredResult.push({
|
|
||||||
type: "point",
|
|
||||||
id: String(sight.id),
|
|
||||||
data: sight,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clusterSights: SightData[] = [];
|
const clusterSights: SightData[] = [];
|
||||||
const queue = [sight];
|
const queue = [sight];
|
||||||
sight.visited = true;
|
sight.visited = true;
|
||||||
@@ -177,12 +167,8 @@ const useSightClustering = (
|
|||||||
clusterSights.push(current);
|
clusterSights.push(current);
|
||||||
|
|
||||||
for (const potentialNeighbor of unclusteredSights) {
|
for (const potentialNeighbor of unclusteredSights) {
|
||||||
const neighborHasCustomIcon =
|
|
||||||
potentialNeighbor.is_default_icon === false &&
|
|
||||||
!isMediaIdEmpty(potentialNeighbor.icon ?? null);
|
|
||||||
if (
|
if (
|
||||||
!potentialNeighbor.visited &&
|
!potentialNeighbor.visited &&
|
||||||
!neighborHasCustomIcon &&
|
|
||||||
clusterSights.length < 4 &&
|
clusterSights.length < 4 &&
|
||||||
getDistance(current, potentialNeighbor) < distanceThreshold
|
getDistance(current, potentialNeighbor) < distanceThreshold
|
||||||
) {
|
) {
|
||||||
@@ -192,10 +178,6 @@ const useSightClustering = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const leftover of queue) {
|
|
||||||
leftover.visited = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clusterSights.length > 1) {
|
if (clusterSights.length > 1) {
|
||||||
let furthestSight: SightData | null = null;
|
let furthestSight: SightData | null = null;
|
||||||
let maxDistanceToPath = -1;
|
let maxDistanceToPath = -1;
|
||||||
@@ -402,14 +384,12 @@ export const WebGLMap = observer(() => {
|
|||||||
return livePercent;
|
return livePercent;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sight?.is_default_icon === false) {
|
if (
|
||||||
if (
|
sight != null &&
|
||||||
typeof sight.icon_size === "number" &&
|
typeof sight.icon_size === "number" &&
|
||||||
Number.isFinite(sight.icon_size)
|
Number.isFinite(sight.icon_size)
|
||||||
) {
|
) {
|
||||||
return sight.icon_size;
|
return sight.icon_size;
|
||||||
}
|
|
||||||
return 100;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -444,7 +424,7 @@ export const WebGLMap = observer(() => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clampPosition = useCallback(
|
const clampPosition = useCallback(
|
||||||
(pos: { x: number; y: number }, _currentScale: number) => {
|
(pos: { x: number; y: number }, currentScale: number) => {
|
||||||
return pos;
|
return pos;
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -819,8 +799,7 @@ export const WebGLMap = observer(() => {
|
|||||||
const textBlockPositionX = rx + labelOffsetX;
|
const textBlockPositionX = rx + labelOffsetX;
|
||||||
const textBlockPositionY = ry + labelOffsetY;
|
const textBlockPositionY = ry + labelOffsetY;
|
||||||
|
|
||||||
const normalizedName = st.name.replace(/\\n|\n/g, "");
|
const approximateTextWidth = st.name.length * fontSize * 0.6;
|
||||||
const approximateTextWidth = normalizedName.length * fontSize * 0.6;
|
|
||||||
const textWidthInMapCoords = approximateTextWidth / scale;
|
const textWidthInMapCoords = approximateTextWidth / scale;
|
||||||
|
|
||||||
let anchorXOffset = 0;
|
let anchorXOffset = 0;
|
||||||
@@ -850,8 +829,8 @@ export const WebGLMap = observer(() => {
|
|||||||
result.push({
|
result.push({
|
||||||
x: sx,
|
x: sx,
|
||||||
y: sy,
|
y: sy,
|
||||||
name: normalizedName,
|
name: st.name,
|
||||||
sub: sub ? sub.replace(/\\n|\n/g, "") : sub,
|
sub,
|
||||||
anchorX: anchorX,
|
anchorX: anchorX,
|
||||||
anchorY: anchorY,
|
anchorY: anchorY,
|
||||||
distance: distanceInPixels,
|
distance: distanceInPixels,
|
||||||
@@ -901,43 +880,6 @@ export const WebGLMap = observer(() => {
|
|||||||
rotationAngle,
|
rotationAngle,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Сегментный индекс каждой упорядоченной станции на routePath (canvas-пространство)
|
|
||||||
const orderedStationSegs = useMemo(() => {
|
|
||||||
if (!orderedRouteStations || !stationData || routePath.length < 4)
|
|
||||||
return [] as number[];
|
|
||||||
return (orderedRouteStations as any[]).map((ordStation) => {
|
|
||||||
const stIdx = stationData.findIndex(
|
|
||||||
(s: any) => String(s.id) === String(ordStation.id),
|
|
||||||
);
|
|
||||||
if (stIdx < 0) return -1;
|
|
||||||
const sx = stationPoints[stIdx * 2];
|
|
||||||
const sy = stationPoints[stIdx * 2 + 1];
|
|
||||||
if (sx === undefined || sy === undefined) return -1;
|
|
||||||
let best = -1,
|
|
||||||
bestD = Infinity;
|
|
||||||
for (let i = 0; i < routePath.length - 2; i += 2) {
|
|
||||||
const p1x = routePath[i],
|
|
||||||
p1y = routePath[i + 1];
|
|
||||||
const p2x = routePath[i + 2],
|
|
||||||
p2y = routePath[i + 3];
|
|
||||||
const dx = p2x - p1x,
|
|
||||||
dy = p2y - p1y;
|
|
||||||
const len2 = dx * dx + dy * dy;
|
|
||||||
if (!len2) continue;
|
|
||||||
const t = ((sx - p1x) * dx + (sy - p1y) * dy) / len2;
|
|
||||||
const cl = Math.max(0, Math.min(1, t));
|
|
||||||
const px = p1x + cl * dx,
|
|
||||||
py = p1y + cl * dy;
|
|
||||||
const d = Math.hypot(sx - px, sy - py);
|
|
||||||
if (d < bestD) {
|
|
||||||
bestD = d;
|
|
||||||
best = i / 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
});
|
|
||||||
}, [orderedRouteStations, stationData, stationPoints, routePath]);
|
|
||||||
|
|
||||||
const sightPoints = useMemo(() => {
|
const sightPoints = useMemo(() => {
|
||||||
if (!sightData || !routeData) return new Float32Array();
|
if (!sightData || !routeData) return new Float32Array();
|
||||||
const centerLat = routeData.center_latitude;
|
const centerLat = routeData.center_latitude;
|
||||||
@@ -1155,8 +1097,6 @@ export const WebGLMap = observer(() => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const prevPositionIndexRef = useRef<number>(-1);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const centerLat = routeData?.center_latitude;
|
const centerLat = routeData?.center_latitude;
|
||||||
const centerLon = routeData?.center_longitude;
|
const centerLon = routeData?.center_longitude;
|
||||||
@@ -1174,16 +1114,7 @@ export const WebGLMap = observer(() => {
|
|||||||
const rx = x * cos - y * sin;
|
const rx = x * cos - y * sin;
|
||||||
const ry = x * sin + y * cos;
|
const ry = x * sin + y * cos;
|
||||||
|
|
||||||
const curIdx = apiStore.positionIndex;
|
if (apiStore.simulationInstantMove) {
|
||||||
const prevIdx = prevPositionIndexRef.current;
|
|
||||||
const pathLen = apiStore.route?.path?.length ?? 0;
|
|
||||||
const isWrap =
|
|
||||||
prevIdx >= 0 &&
|
|
||||||
pathLen > 0 &&
|
|
||||||
Math.abs(curIdx - prevIdx) > pathLen / 4;
|
|
||||||
prevPositionIndexRef.current = curIdx;
|
|
||||||
|
|
||||||
if (apiStore.simulationInstantMove || isWrap) {
|
|
||||||
setYellowDotImmediate(rx, ry);
|
setYellowDotImmediate(rx, ry);
|
||||||
} else {
|
} else {
|
||||||
animateYellowDotTo(rx, ry);
|
animateYellowDotTo(rx, ry);
|
||||||
@@ -1234,8 +1165,8 @@ export const WebGLMap = observer(() => {
|
|||||||
gl.enableVertexAttribArray(attribs.a_pos);
|
gl.enableVertexAttribArray(attribs.a_pos);
|
||||||
gl.vertexAttribPointer(attribs.a_pos, 2, gl.FLOAT, false, 0, 0);
|
gl.vertexAttribPointer(attribs.a_pos, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
const vcount = routePath.length / 2;
|
||||||
let tramSegIndex = getCurrentSegIndex();
|
let tramSegIndex = getCurrentSegIndex();
|
||||||
const simulationDirection = apiStore.simulationDirection;
|
|
||||||
|
|
||||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||||
const desiredRouteWidthCss = 7;
|
const desiredRouteWidthCss = 7;
|
||||||
@@ -1333,88 +1264,56 @@ export const WebGLMap = observer(() => {
|
|||||||
const r1 = ((PATH_COLOR >> 16) & 0xff) / 255;
|
const r1 = ((PATH_COLOR >> 16) & 0xff) / 255;
|
||||||
const g1 = ((PATH_COLOR >> 8) & 0xff) / 255;
|
const g1 = ((PATH_COLOR >> 8) & 0xff) / 255;
|
||||||
const b1 = (PATH_COLOR & 0xff) / 255;
|
const b1 = (PATH_COLOR & 0xff) / 255;
|
||||||
|
gl.uniform4f(uniforms.u_color, r1, g1, b1, 1);
|
||||||
|
|
||||||
|
if (tramSegIndex >= 0) {
|
||||||
|
const animatedPos = animatedYellowDotPosition;
|
||||||
|
if (
|
||||||
|
animatedPos &&
|
||||||
|
animatedPos.x !== undefined &&
|
||||||
|
animatedPos.y !== undefined
|
||||||
|
) {
|
||||||
|
const passedPoints: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i <= tramSegIndex; i++) {
|
||||||
|
passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
passedPoints.push(animatedPos.x, animatedPos.y);
|
||||||
|
|
||||||
|
if (passedPoints.length >= 4) {
|
||||||
|
const thickLineVertices = generateThickLine(
|
||||||
|
new Float32Array(passedPoints),
|
||||||
|
lineWidth,
|
||||||
|
);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW);
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const r2 = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255;
|
const r2 = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255;
|
||||||
const g2 = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255;
|
const g2 = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255;
|
||||||
const b2 = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
const b2 = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||||
|
gl.uniform4f(uniforms.u_color, r2, g2, b2, 1);
|
||||||
|
|
||||||
const animatedPos = animatedYellowDotPosition;
|
const animatedPos = animatedYellowDotPosition;
|
||||||
if (
|
if (
|
||||||
tramSegIndex >= 0 &&
|
|
||||||
animatedPos &&
|
animatedPos &&
|
||||||
animatedPos.x !== undefined &&
|
animatedPos.x !== undefined &&
|
||||||
animatedPos.y !== undefined
|
animatedPos.y !== undefined
|
||||||
) {
|
) {
|
||||||
if (simulationDirection === 1) {
|
const unpassedPoints: number[] = [];
|
||||||
// Вперёд: закрашено от начала до трамвая
|
|
||||||
gl.uniform4f(uniforms.u_color, r1, g1, b1, 1);
|
unpassedPoints.push(animatedPos.x, animatedPos.y);
|
||||||
const passedPoints: number[] = [];
|
|
||||||
for (let i = 0; i <= tramSegIndex; i++) {
|
for (let i = tramSegIndex + 1; i < vertexCount; i++) {
|
||||||
passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
||||||
}
|
|
||||||
passedPoints.push(animatedPos.x, animatedPos.y);
|
|
||||||
if (passedPoints.length >= 4) {
|
|
||||||
const thickLineVertices = generateThickLine(
|
|
||||||
new Float32Array(passedPoints),
|
|
||||||
lineWidth,
|
|
||||||
);
|
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW);
|
|
||||||
gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2);
|
|
||||||
}
|
|
||||||
gl.uniform4f(uniforms.u_color, r2, g2, b2, 1);
|
|
||||||
const unpassedPoints: number[] = [];
|
|
||||||
unpassedPoints.push(animatedPos.x, animatedPos.y);
|
|
||||||
for (let i = tramSegIndex + 1; i < vertexCount; i++) {
|
|
||||||
unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
|
||||||
}
|
|
||||||
if (unpassedPoints.length >= 4) {
|
|
||||||
const thickLineVertices = generateThickLine(
|
|
||||||
new Float32Array(unpassedPoints),
|
|
||||||
lineWidth,
|
|
||||||
);
|
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW);
|
|
||||||
gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Назад: закрашено от трамвая до конца
|
|
||||||
gl.uniform4f(uniforms.u_color, r2, g2, b2, 1);
|
|
||||||
const unpassedPoints: number[] = [];
|
|
||||||
for (let i = 0; i <= tramSegIndex; i++) {
|
|
||||||
unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
|
||||||
}
|
|
||||||
unpassedPoints.push(animatedPos.x, animatedPos.y);
|
|
||||||
if (unpassedPoints.length >= 4) {
|
|
||||||
const thickLineVertices = generateThickLine(
|
|
||||||
new Float32Array(unpassedPoints),
|
|
||||||
lineWidth,
|
|
||||||
);
|
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW);
|
|
||||||
gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2);
|
|
||||||
}
|
|
||||||
gl.uniform4f(uniforms.u_color, r1, g1, b1, 1);
|
|
||||||
const passedPoints: number[] = [];
|
|
||||||
passedPoints.push(animatedPos.x, animatedPos.y);
|
|
||||||
for (let i = tramSegIndex + 1; i < vertexCount; i++) {
|
|
||||||
passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
|
||||||
}
|
|
||||||
if (passedPoints.length >= 4) {
|
|
||||||
const thickLineVertices = generateThickLine(
|
|
||||||
new Float32Array(passedPoints),
|
|
||||||
lineWidth,
|
|
||||||
);
|
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW);
|
|
||||||
gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Позиция трамвая неизвестна — рисуем весь маршрут серым
|
if (unpassedPoints.length >= 4) {
|
||||||
gl.uniform4f(uniforms.u_color, r2, g2, b2, 1);
|
|
||||||
const allPoints: number[] = [];
|
|
||||||
for (let i = 0; i < vertexCount; i++) {
|
|
||||||
allPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
|
||||||
}
|
|
||||||
if (allPoints.length >= 4) {
|
|
||||||
const thickLineVertices = generateThickLine(
|
const thickLineVertices = generateThickLine(
|
||||||
new Float32Array(allPoints),
|
new Float32Array(unpassedPoints),
|
||||||
lineWidth,
|
lineWidth,
|
||||||
);
|
);
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW);
|
||||||
@@ -1449,72 +1348,92 @@ export const WebGLMap = observer(() => {
|
|||||||
|
|
||||||
gl.uniform1f(u_pointSize, pointInnerSizePx);
|
gl.uniform1f(u_pointSize, pointInnerSizePx);
|
||||||
|
|
||||||
|
let currentStationIndexInOrdered = -1;
|
||||||
|
if (currentStationId && orderedRouteStations) {
|
||||||
|
currentStationIndexInOrdered = orderedRouteStations.findIndex(
|
||||||
|
(station: any) => String(station.id) === String(currentStationId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
tramSegIndex >= 0 &&
|
currentStationIndexInOrdered >= 0 &&
|
||||||
orderedRouteStations &&
|
orderedRouteStations &&
|
||||||
stationData &&
|
stationData
|
||||||
orderedStationSegs.length > 0
|
|
||||||
) {
|
) {
|
||||||
const passedPts1: number[] = [];
|
const passedStations: number[] = [];
|
||||||
const unpassedPts1: number[] = [];
|
|
||||||
for (let i = 0; i < orderedRouteStations.length; i++) {
|
for (let i = 0; i < currentStationIndexInOrdered; i++) {
|
||||||
const orderedStation = (orderedRouteStations as any[])[i];
|
const orderedStation = orderedRouteStations[i];
|
||||||
const stationSeg = orderedStationSegs[i] ?? -1;
|
if (orderedStation) {
|
||||||
if (!orderedStation || stationSeg < 0) continue;
|
const stationIndexInData = stationData.findIndex(
|
||||||
const isPassed =
|
(station: any) =>
|
||||||
simulationDirection === 1
|
String(station.id) === String(orderedStation.id),
|
||||||
? stationSeg < tramSegIndex
|
);
|
||||||
: stationSeg > tramSegIndex;
|
if (stationIndexInData >= 0) {
|
||||||
const stIdx = stationData.findIndex(
|
passedStations.push(
|
||||||
(s: any) => String(s.id) === String(orderedStation.id),
|
stationPoints[stationIndexInData * 2] as number,
|
||||||
);
|
stationPoints[stationIndexInData * 2 + 1] as number,
|
||||||
if (stIdx < 0) continue;
|
);
|
||||||
const sx = stationPoints[stIdx * 2] as number;
|
}
|
||||||
const sy = stationPoints[stIdx * 2 + 1] as number;
|
|
||||||
if (isPassed) {
|
|
||||||
passedPts1.push(sx, sy);
|
|
||||||
} else {
|
|
||||||
unpassedPts1.push(sx, sy);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (passedPts1.length > 0) {
|
if (passedStations.length > 0) {
|
||||||
gl.uniform4f(
|
const r_passed = ((PATH_COLOR >> 16) & 0xff) / 255;
|
||||||
u_color_pts,
|
const g_passed = ((PATH_COLOR >> 8) & 0xff) / 255;
|
||||||
((PATH_COLOR >> 16) & 0xff) / 255,
|
const b_passed = (PATH_COLOR & 0xff) / 255;
|
||||||
((PATH_COLOR >> 8) & 0xff) / 255,
|
gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1);
|
||||||
(PATH_COLOR & 0xff) / 255,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
gl.bufferData(
|
gl.bufferData(
|
||||||
gl.ARRAY_BUFFER,
|
gl.ARRAY_BUFFER,
|
||||||
new Float32Array(passedPts1),
|
new Float32Array(passedStations),
|
||||||
gl.STATIC_DRAW,
|
gl.STATIC_DRAW,
|
||||||
);
|
);
|
||||||
gl.drawArrays(gl.POINTS, 0, passedPts1.length / 2);
|
gl.drawArrays(gl.POINTS, 0, passedStations.length / 2);
|
||||||
}
|
}
|
||||||
if (unpassedPts1.length > 0) {
|
}
|
||||||
gl.uniform4f(
|
|
||||||
u_color_pts,
|
if (
|
||||||
((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255,
|
currentStationIndexInOrdered >= 0 &&
|
||||||
((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255,
|
orderedRouteStations &&
|
||||||
(UNPASSED_STATION_COLOR & 0xff) / 255,
|
stationData
|
||||||
1,
|
) {
|
||||||
);
|
const unpassedStations: number[] = [];
|
||||||
|
|
||||||
|
for (
|
||||||
|
let i = currentStationIndexInOrdered + 1;
|
||||||
|
i < orderedRouteStations.length;
|
||||||
|
i++
|
||||||
|
) {
|
||||||
|
const orderedStation = orderedRouteStations[i];
|
||||||
|
if (orderedStation) {
|
||||||
|
const stationIndexInData = stationData.findIndex(
|
||||||
|
(station: any) =>
|
||||||
|
String(station.id) === String(orderedStation.id),
|
||||||
|
);
|
||||||
|
if (stationIndexInData >= 0) {
|
||||||
|
unpassedStations.push(
|
||||||
|
stationPoints[stationIndexInData * 2] as number,
|
||||||
|
stationPoints[stationIndexInData * 2 + 1] as number,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (unpassedStations.length > 0) {
|
||||||
|
const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255;
|
||||||
|
const g_unpassed = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255;
|
||||||
|
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||||
|
gl.uniform4f(u_color_pts, r_unpassed, g_unpassed, b_unpassed, 1);
|
||||||
gl.bufferData(
|
gl.bufferData(
|
||||||
gl.ARRAY_BUFFER,
|
gl.ARRAY_BUFFER,
|
||||||
new Float32Array(unpassedPts1),
|
new Float32Array(unpassedStations),
|
||||||
gl.STATIC_DRAW,
|
gl.STATIC_DRAW,
|
||||||
);
|
);
|
||||||
gl.drawArrays(gl.POINTS, 0, unpassedPts1.length / 2);
|
gl.drawArrays(gl.POINTS, 0, unpassedStations.length / 2);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
gl.uniform4f(
|
const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255;
|
||||||
u_color_pts,
|
const g_unpassed = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255;
|
||||||
((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255,
|
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||||
((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255,
|
gl.uniform4f(u_color_pts, r_unpassed, g_unpassed, b_unpassed, 1);
|
||||||
(UNPASSED_STATION_COLOR & 0xff) / 255,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW);
|
||||||
gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2);
|
gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2);
|
||||||
}
|
}
|
||||||
@@ -1533,6 +1452,53 @@ export const WebGLMap = observer(() => {
|
|||||||
|
|
||||||
const toPointsArray = (arr: number[]) => new Float32Array(arr);
|
const toPointsArray = (arr: number[]) => new Float32Array(arr);
|
||||||
|
|
||||||
|
const pathPts: { x: number; y: number }[] = [];
|
||||||
|
for (let i = 0; i < routePath.length; i += 2)
|
||||||
|
pathPts.push({ x: routePath[i], y: routePath[i + 1] });
|
||||||
|
const getSeg = (px: number, py: number) => {
|
||||||
|
if (pathPts.length < 2) return -1;
|
||||||
|
let best = -1,
|
||||||
|
bestD = Infinity;
|
||||||
|
for (let i = 0; i < pathPts.length - 1; i++) {
|
||||||
|
const p1 = pathPts[i],
|
||||||
|
p2 = pathPts[i + 1];
|
||||||
|
const dx = p2.x - p1.x,
|
||||||
|
dy = p2.y - p1.y;
|
||||||
|
const len2 = dx * dx + dy * dy;
|
||||||
|
if (!len2) continue;
|
||||||
|
const t = ((px - p1.x) * dx + (py - p1.y) * dy) / len2;
|
||||||
|
const tt = Math.max(0, Math.min(1, t));
|
||||||
|
const cx = p1.x + tt * dx,
|
||||||
|
cy = p1.y + tt * dy;
|
||||||
|
const d = Math.hypot(px - cx, py - cy);
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
|
let tramSegForStations = -1;
|
||||||
|
{
|
||||||
|
const cLat = routeData?.center_latitude,
|
||||||
|
cLon = routeData?.center_longitude;
|
||||||
|
const tram = apiStore?.context?.currentCoordinates as any;
|
||||||
|
if (tram && cLat !== undefined && cLon !== undefined) {
|
||||||
|
const loc = coordinatesToLocal(
|
||||||
|
tram.latitude - cLat,
|
||||||
|
tram.longitude - cLon,
|
||||||
|
);
|
||||||
|
const wx = loc.x * UP_SCALE,
|
||||||
|
wy = loc.y * UP_SCALE;
|
||||||
|
const cosR = Math.cos(rotationAngle),
|
||||||
|
sinR = Math.sin(rotationAngle);
|
||||||
|
const tx = wx * cosR - wy * sinR,
|
||||||
|
ty = wx * sinR + wy * cosR;
|
||||||
|
tramSegForStations = getSeg(tx, ty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let activeStationIndex = -1;
|
let activeStationIndex = -1;
|
||||||
const tramCoords = apiStore?.context?.currentCoordinates;
|
const tramCoords = apiStore?.context?.currentCoordinates;
|
||||||
if (
|
if (
|
||||||
@@ -1585,26 +1551,37 @@ export const WebGLMap = observer(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentStationIndexInOrdered = -1;
|
||||||
|
if (currentStationId && orderedRouteStations) {
|
||||||
|
currentStationIndexInOrdered = orderedRouteStations.findIndex(
|
||||||
|
(station: any) => String(station.id) === String(currentStationId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const passedStationIds = new Set<string>();
|
const passedStationIds = new Set<string>();
|
||||||
const unpassedStationIds = new Set<string>();
|
const unpassedStationIds = new Set<string>();
|
||||||
|
|
||||||
if (
|
if (currentStationIndexInOrdered >= 0 && orderedRouteStations) {
|
||||||
tramSegIndex >= 0 &&
|
for (let i = 0; i < currentStationIndexInOrdered; i++) {
|
||||||
orderedRouteStations &&
|
const station = orderedRouteStations[i];
|
||||||
orderedStationSegs.length === orderedRouteStations.length
|
if (station) {
|
||||||
) {
|
passedStationIds.add(String(station.id));
|
||||||
for (let i = 0; i < orderedRouteStations.length; i++) {
|
}
|
||||||
const station = (orderedRouteStations as any[])[i];
|
}
|
||||||
const seg = orderedStationSegs[i] ?? -1;
|
|
||||||
if (!station || seg < 0) continue;
|
for (
|
||||||
const isPassed =
|
let i = currentStationIndexInOrdered;
|
||||||
simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex;
|
i < orderedRouteStations.length;
|
||||||
if (isPassed) passedStationIds.add(String(station.id));
|
i++
|
||||||
else unpassedStationIds.add(String(station.id));
|
) {
|
||||||
|
const station = orderedRouteStations[i];
|
||||||
|
if (station) {
|
||||||
|
unpassedStationIds.add(String(station.id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (orderedRouteStations) {
|
if (orderedRouteStations) {
|
||||||
(orderedRouteStations as any[]).forEach((station) => {
|
orderedRouteStations.forEach((station: any) => {
|
||||||
unpassedStationIds.add(String(station.id));
|
unpassedStationIds.add(String(station.id));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1741,27 +1718,12 @@ export const WebGLMap = observer(() => {
|
|||||||
const cos = Math.cos(rotationAngle);
|
const cos = Math.cos(rotationAngle);
|
||||||
const sin = Math.sin(rotationAngle);
|
const sin = Math.sin(rotationAngle);
|
||||||
|
|
||||||
const startStationData = orderedRouteStations?.[0]
|
const startStationData = stationData.find(
|
||||||
? stationData.find(
|
(station) => station.id.toString() === apiStore.context?.startStopId,
|
||||||
(station: any) =>
|
);
|
||||||
station.id.toString() === String(orderedRouteStations[0].id),
|
const endStationData = stationData.find(
|
||||||
)
|
(station) => station.id.toString() === apiStore.context?.endStopId,
|
||||||
: stationData.find(
|
);
|
||||||
(station: any) =>
|
|
||||||
station.id.toString() === apiStore.context?.startStopId,
|
|
||||||
);
|
|
||||||
const endStationData = orderedRouteStations?.length
|
|
||||||
? stationData.find(
|
|
||||||
(station: any) =>
|
|
||||||
station.id.toString() ===
|
|
||||||
String(
|
|
||||||
orderedRouteStations[orderedRouteStations.length - 1].id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: stationData.find(
|
|
||||||
(station: any) =>
|
|
||||||
station.id.toString() === apiStore.context?.endStopId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const terminalStations: number[] = [];
|
const terminalStations: number[] = [];
|
||||||
|
|
||||||
@@ -1861,13 +1823,7 @@ export const WebGLMap = observer(() => {
|
|||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
})();
|
})();
|
||||||
return (
|
return tramSegIndex !== -1 && seg !== -1 && seg < tramSegIndex;
|
||||||
tramSegIndex !== -1 &&
|
|
||||||
seg !== -1 &&
|
|
||||||
(simulationDirection === 1
|
|
||||||
? seg < tramSegIndex
|
|
||||||
: seg > tramSegIndex)
|
|
||||||
);
|
|
||||||
})()
|
})()
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
@@ -1900,13 +1856,7 @@ export const WebGLMap = observer(() => {
|
|||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
})();
|
})();
|
||||||
return (
|
return tramSegIndex !== -1 && seg !== -1 && seg < tramSegIndex;
|
||||||
tramSegIndex !== -1 &&
|
|
||||||
seg !== -1 &&
|
|
||||||
(simulationDirection === 1
|
|
||||||
? seg < tramSegIndex
|
|
||||||
: seg > tramSegIndex)
|
|
||||||
);
|
|
||||||
})()
|
})()
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
@@ -1932,24 +1882,20 @@ export const WebGLMap = observer(() => {
|
|||||||
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||||
|
|
||||||
if (startStationData && endStationData) {
|
if (startStationData && endStationData) {
|
||||||
const startIsPassed =
|
gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1.0);
|
||||||
simulationDirection === 1 ? true : isStartPassed;
|
|
||||||
const endIsPassed = simulationDirection === -1 ? true : isEndPassed;
|
|
||||||
gl.uniform4f(
|
|
||||||
u_color_pts,
|
|
||||||
startIsPassed ? r_passed : r_unpassed,
|
|
||||||
startIsPassed ? g_passed : g_unpassed,
|
|
||||||
startIsPassed ? b_passed : b_unpassed,
|
|
||||||
1.0,
|
|
||||||
);
|
|
||||||
gl.drawArrays(gl.POINTS, 0, 1);
|
gl.drawArrays(gl.POINTS, 0, 1);
|
||||||
gl.uniform4f(
|
|
||||||
u_color_pts,
|
if (isEndPassed) {
|
||||||
endIsPassed ? r_passed : r_unpassed,
|
gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1.0);
|
||||||
endIsPassed ? g_passed : g_unpassed,
|
} else {
|
||||||
endIsPassed ? b_passed : b_unpassed,
|
gl.uniform4f(
|
||||||
1.0,
|
u_color_pts,
|
||||||
);
|
r_unpassed,
|
||||||
|
g_unpassed,
|
||||||
|
b_unpassed,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
gl.drawArrays(gl.POINTS, 1, 1);
|
gl.drawArrays(gl.POINTS, 1, 1);
|
||||||
} else {
|
} else {
|
||||||
const isStartStation = startStationData !== undefined;
|
const isStartStation = startStationData !== undefined;
|
||||||
@@ -1989,8 +1935,6 @@ export const WebGLMap = observer(() => {
|
|||||||
nearestStationId,
|
nearestStationId,
|
||||||
currentStationId,
|
currentStationId,
|
||||||
orderedRouteStations,
|
orderedRouteStations,
|
||||||
orderedStationSegs,
|
|
||||||
apiStore.simulationDirection,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2387,10 +2331,11 @@ export const WebGLMap = observer(() => {
|
|||||||
? { right: 0, transform: "none" }
|
? { right: 0, transform: "none" }
|
||||||
: { left: "50%", transform: "translateX(-50%)" };
|
: { left: "50%", transform: "translateX(-50%)" };
|
||||||
|
|
||||||
|
const apiBaseUrl = apiBaseURL;
|
||||||
const isMediaIdEmptyResult = isMediaIdEmpty(station?.icon);
|
const isMediaIdEmptyResult = isMediaIdEmpty(station?.icon);
|
||||||
const iconSrc = isMediaIdEmptyResult
|
const iconSrc = isMediaIdEmptyResult
|
||||||
? null
|
? null
|
||||||
: buildMediaDownloadUrl(mediaBaseUrl, station!.icon!, mediaToken);
|
: `${apiBaseUrl}/media/${station?.icon}/download`;
|
||||||
const iconSizePx = Math.round(primaryFontSize * 1.2);
|
const iconSizePx = Math.round(primaryFontSize * 1.2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2536,11 +2481,6 @@ export const WebGLMap = observer(() => {
|
|||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
...(apiStore.showHitboxes && {
|
|
||||||
outline: "2px solid rgba(0,255,0,0.8)",
|
|
||||||
outlineOffset: "2px",
|
|
||||||
backgroundColor: "rgba(0,255,0,0.08)",
|
|
||||||
}),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -2573,8 +2513,14 @@ export const WebGLMap = observer(() => {
|
|||||||
const rx = cluster.longitude;
|
const rx = cluster.longitude;
|
||||||
const ry = cluster.latitude;
|
const ry = cluster.latitude;
|
||||||
|
|
||||||
|
const iconSizePercent = resolveSightIconSizePercent();
|
||||||
|
const iconSize =
|
||||||
|
SIGHT_ICON_BASE_SIZE * clamp(iconSizePercent / 100, 0.1, 10);
|
||||||
|
|
||||||
const screenX = (rx * scale + position.x) / dpr;
|
const screenX = (rx * scale + position.x) / dpr;
|
||||||
const screenY = (ry * scale + position.y) / dpr;
|
const screenY = (ry * scale + position.y) / dpr;
|
||||||
|
const iconLeft = screenX - iconSize / 2;
|
||||||
|
const iconTop = screenY - iconSize / 2;
|
||||||
|
|
||||||
const isExpanded = activeClusterId === cluster.id;
|
const isExpanded = activeClusterId === cluster.id;
|
||||||
const selectedSightInCluster = cluster.sights.find(
|
const selectedSightInCluster = cluster.sights.find(
|
||||||
@@ -2586,20 +2532,6 @@ export const WebGLMap = observer(() => {
|
|||||||
const selectedIsCustomInCluster =
|
const selectedIsCustomInCluster =
|
||||||
selectedSightInCluster?.is_default_icon === false &&
|
selectedSightInCluster?.is_default_icon === false &&
|
||||||
selectedHasIconInCluster;
|
selectedHasIconInCluster;
|
||||||
|
|
||||||
const iconSizePercent = resolveSightIconSizePercent(
|
|
||||||
cluster.sights[0],
|
|
||||||
);
|
|
||||||
const clusterCustomScaleFactor = selectedIsCustomInCluster
|
|
||||||
? scale / Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6)
|
|
||||||
: 1;
|
|
||||||
const iconSize =
|
|
||||||
SIGHT_ICON_BASE_SIZE *
|
|
||||||
clamp(iconSizePercent / 100, 0.1, 10) *
|
|
||||||
clusterCustomScaleFactor;
|
|
||||||
|
|
||||||
const iconLeft = screenX - iconSize;
|
|
||||||
const iconTop = screenY - iconSize;
|
|
||||||
const hasSelectedAltIconInCluster =
|
const hasSelectedAltIconInCluster =
|
||||||
selectedSightInCluster != null &&
|
selectedSightInCluster != null &&
|
||||||
selectedIsCustomInCluster &&
|
selectedIsCustomInCluster &&
|
||||||
@@ -2657,7 +2589,7 @@ export const WebGLMap = observer(() => {
|
|||||||
)
|
)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const badgeColor = "var(--carrier-main, #006F3A)";
|
const badgeColor = "#006F3A";
|
||||||
const listPanelWidth = 200;
|
const listPanelWidth = 200;
|
||||||
const listItemHeight = 30;
|
const listItemHeight = 30;
|
||||||
const listMaxHeight = 250;
|
const listMaxHeight = 250;
|
||||||
@@ -2673,38 +2605,29 @@ export const WebGLMap = observer(() => {
|
|||||||
onTouchEnd={handleClusterClick}
|
onTouchEnd={handleClusterClick}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: iconLeft,
|
left: iconLeft - CLUSTER_RADIUS_BASE - 10,
|
||||||
top: iconTop,
|
top: iconTop - CLUSTER_RADIUS_BASE - 10,
|
||||||
width: iconSize,
|
width: iconSize + CLUSTER_RADIUS_BASE * 2 + 20,
|
||||||
height: iconSize,
|
height: iconSize + CLUSTER_RADIUS_BASE * 2 + 20,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
...(apiStore.showHitboxes && {
|
|
||||||
outline: "2px solid rgba(255,165,0,0.8)",
|
|
||||||
outlineOffset: "2px",
|
|
||||||
backgroundColor: "rgba(255,165,0,0.08)",
|
|
||||||
}),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div style={{ position: "relative" }}>
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: iconSize,
|
|
||||||
height: iconSize,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
src={clusterIconUrl}
|
src={clusterIconUrl}
|
||||||
alt=""
|
alt=""
|
||||||
|
width={iconSize}
|
||||||
|
height={iconSize}
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "block",
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
filter: hasSelectedSight
|
filter: hasSelectedSight
|
||||||
? hasSelectedAltIconInCluster
|
? hasSelectedAltIconInCluster
|
||||||
? "none"
|
? "none"
|
||||||
@@ -2717,7 +2640,6 @@ export const WebGLMap = observer(() => {
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: -6,
|
top: -6,
|
||||||
right: -6,
|
right: -6,
|
||||||
zIndex: 1,
|
|
||||||
width: 15,
|
width: 15,
|
||||||
height: 15,
|
height: 15,
|
||||||
borderRadius: "10px",
|
borderRadius: "10px",
|
||||||
@@ -2740,20 +2662,16 @@ export const WebGLMap = observer(() => {
|
|||||||
<div
|
<div
|
||||||
data-expanded-cluster={cluster.id}
|
data-expanded-cluster={cluster.id}
|
||||||
onTouchStart={handleCircleInteraction}
|
onTouchStart={handleCircleInteraction}
|
||||||
|
onTouchMove={handleCircleInteraction}
|
||||||
onMouseMove={handleCircleInteraction}
|
onMouseMove={handleCircleInteraction}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: screenX - iconSize,
|
left: screenX - iconSize / 2,
|
||||||
top: screenY - iconSize,
|
top: screenY - iconSize / 2,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
zIndex: 100000000000000,
|
zIndex: 100000000000000,
|
||||||
...(apiStore.showHitboxes && {
|
|
||||||
outline: "2px solid rgba(0,180,255,0.8)",
|
|
||||||
outlineOffset: "2px",
|
|
||||||
backgroundColor: "rgba(0,180,255,0.08)",
|
|
||||||
}),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -2805,14 +2723,15 @@ export const WebGLMap = observer(() => {
|
|||||||
<div
|
<div
|
||||||
className="cluster-sights-list"
|
className="cluster-sights-list"
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4)`,
|
background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(0, 111, 58, 0.4)`,
|
||||||
backdropFilter: "blur(10px)",
|
backdropFilter: "blur(10px)",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
width: listPanelWidth,
|
width: listPanelWidth,
|
||||||
maxHeight: hasMoreThanTwo ? listMaxHeight : undefined,
|
maxHeight: hasMoreThanTwo ? listMaxHeight : undefined,
|
||||||
boxShadow:
|
boxShadow:
|
||||||
"inset 0 0 0 1px rgba(255, 255, 255, 0.3), inset 4px 4px 12px 0 rgba(255, 255, 255, 0.12)",
|
"0 0 0 1px rgba(255, 255, 255, 0.3) inset, 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -2955,6 +2874,7 @@ export const WebGLMap = observer(() => {
|
|||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
}}
|
}}
|
||||||
|
title={sightName}
|
||||||
>
|
>
|
||||||
{sightName}
|
{sightName}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import React, {
|
|||||||
useContext,
|
useContext,
|
||||||
useState,
|
useState,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { UP_SCALE } from "./Constants";
|
||||||
|
|
||||||
const TransformContext = createContext<{
|
const TransformContext = createContext<{
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import subtractHomeIcon from "../../assets/icons/subtract-home.svg";
|
|
||||||
|
|
||||||
function BackButtonSVG({ onPointerUp }) {
|
function BackButtonSVG({ onPointerUp }) {
|
||||||
return (
|
return (
|
||||||
<img
|
<svg
|
||||||
src={subtractHomeIcon}
|
|
||||||
alt=""
|
|
||||||
width="24"
|
|
||||||
height="21"
|
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
className="sight-frame-get-back"
|
className="sight-frame-get-back"
|
||||||
style={{ cursor: "pointer", display: "block" }}
|
width="13"
|
||||||
/>
|
height="22"
|
||||||
|
viewBox="0 0 13 22"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style={{ cursor: "pointer", transform: "rotate(-90deg)" }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4.03042 11.0738C4.21763 11.2087 4.34586 11.2766 4.44415 11.3747C6.92933 13.8687 9.40982 16.3665 11.895 18.8605C12.3396 19.3066 12.5175 19.8207 12.3349 20.4339C12.027 21.4658 10.7764 21.8393 9.95179 21.1498C9.86193 21.0743 9.78049 20.9894 9.69812 20.9064C6.85163 18.0446 4.00421 15.1817 1.15866 12.3189C0.279718 11.4341 0.280654 10.6456 1.16053 9.75982C4.03884 6.86305 6.91341 3.96155 9.8067 1.07986C10.0585 0.828952 10.4133 0.619547 10.7549 0.537482C11.4008 0.382786 12.0139 0.72142 12.3181 1.28644C12.6251 1.85334 12.5381 2.56079 12.0794 3.05129C11.7275 3.4286 11.3549 3.78704 10.9899 4.15209C8.79485 6.35557 6.60078 8.55904 4.40483 10.7606C4.32246 10.8436 4.22605 10.9106 4.03042 11.0729L4.03042 11.0738Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { useRef, useEffect } from "react";
|
|
||||||
|
|
||||||
const Collapsible = ({ open, className = "", children }) => {
|
|
||||||
const ref = useRef(null);
|
|
||||||
const isFirst = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
if (isFirst.current) {
|
|
||||||
isFirst.current = false;
|
|
||||||
el.style.height = open ? "auto" : "0px";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (open) {
|
|
||||||
el.style.height = `${el.scrollHeight}px`;
|
|
||||||
const onEnd = (e) => {
|
|
||||||
if (e.target !== el || e.propertyName !== "height") return;
|
|
||||||
el.style.height = "auto";
|
|
||||||
el.removeEventListener("transitionend", onEnd);
|
|
||||||
};
|
|
||||||
el.addEventListener("transitionend", onEnd);
|
|
||||||
} else {
|
|
||||||
el.style.height = `${el.scrollHeight}px`;
|
|
||||||
void el.offsetHeight;
|
|
||||||
el.style.height = "0px";
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className={`${className} ${open ? "open" : ""}`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Collapsible;
|
|
||||||
@@ -5,8 +5,6 @@ import { useGeolocationStore } from "../../stores";
|
|||||||
import "../../styles/LeftWidget.css";
|
import "../../styles/LeftWidget.css";
|
||||||
import { apiStore } from "../../api/ApiStore/store";
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
import { apiBaseURL } from "../../api/apiConfig";
|
import { apiBaseURL } from "../../api/apiConfig";
|
||||||
import { ReactMarkdownComponent } from "../ReactMarkdown";
|
|
||||||
import { TouchableLayout } from "../TouchableLayout";
|
|
||||||
|
|
||||||
const LeftWidget = observer(
|
const LeftWidget = observer(
|
||||||
({ selectedSightId, onClose, isVisible, sightTop }) => {
|
({ selectedSightId, onClose, isVisible, sightTop }) => {
|
||||||
@@ -17,7 +15,8 @@ const LeftWidget = observer(
|
|||||||
const [isImageLoaded, setIsImageLoaded] = useState(false);
|
const [isImageLoaded, setIsImageLoaded] = useState(false);
|
||||||
const [widgetHeight, setWidgetHeight] = useState(0);
|
const [widgetHeight, setWidgetHeight] = useState(0);
|
||||||
|
|
||||||
const layoutRef = useRef(null);
|
const textRef = useRef(null);
|
||||||
|
const activeTouch = useRef(null);
|
||||||
const widgetRef = useRef(null);
|
const widgetRef = useRef(null);
|
||||||
|
|
||||||
const store = useGeolocationStore();
|
const store = useGeolocationStore();
|
||||||
@@ -38,10 +37,64 @@ const LeftWidget = observer(
|
|||||||
}, [selectedSightData, isImageLoaded, isVisible, isLoading, error]);
|
}, [selectedSightData, isImageLoaded, isVisible, isLoading, error]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (layoutRef.current) {
|
const scrollContainer = textRef.current;
|
||||||
const scrollable = layoutRef.current.querySelector(".scrollable");
|
if (!scrollContainer) return;
|
||||||
if (scrollable) scrollable.scrollTop = 0;
|
|
||||||
}
|
const handleTouchStart = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
activeTouch.current = {
|
||||||
|
identifier: e.touches[0].identifier,
|
||||||
|
lastY: e.touches[0].clientY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeTouch.current) {
|
||||||
|
for (const touch of e.changedTouches) {
|
||||||
|
if (touch.identifier === activeTouch.current.identifier) {
|
||||||
|
const deltaY = touch.clientY - activeTouch.current.lastY;
|
||||||
|
scrollContainer.scrollTop -= deltaY;
|
||||||
|
activeTouch.current.lastY = touch.clientY;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = (e) => {
|
||||||
|
for (const touch of e.changedTouches) {
|
||||||
|
if (
|
||||||
|
activeTouch.current &&
|
||||||
|
touch.identifier === activeTouch.current.identifier
|
||||||
|
) {
|
||||||
|
activeTouch.current = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollContainer.addEventListener("touchstart", handleTouchStart, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
scrollContainer.addEventListener("touchmove", handleTouchMove, {
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
|
scrollContainer.addEventListener("touchend", handleTouchEnd, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
scrollContainer.addEventListener("touchcancel", handleTouchEnd, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollContainer.removeEventListener("touchstart", handleTouchStart);
|
||||||
|
scrollContainer.removeEventListener("touchmove", handleTouchMove);
|
||||||
|
scrollContainer.removeEventListener("touchend", handleTouchEnd);
|
||||||
|
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
|
||||||
|
};
|
||||||
}, [selectedSightData]);
|
}, [selectedSightData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,10 +120,8 @@ const LeftWidget = observer(
|
|||||||
selectedLanguage === "ru"
|
selectedLanguage === "ru"
|
||||||
? routeSights.find((sight) => sight.id === selectedSightId)
|
? routeSights.find((sight) => sight.id === selectedSightId)
|
||||||
: selectedLanguage === "en"
|
: selectedLanguage === "en"
|
||||||
? routeSightsEn.find((sight) => sight.id === selectedSightId)
|
? routeSightsEn.find((sight) => sight.id === selectedSightId)
|
||||||
: routeSightsZh.find((sight) => sight.id === selectedSightId);
|
: routeSightsZh.find((sight) => sight.id === selectedSightId);
|
||||||
|
|
||||||
if (!sight) return;
|
|
||||||
|
|
||||||
const leftArticle = sight.left_article;
|
const leftArticle = sight.left_article;
|
||||||
|
|
||||||
@@ -78,20 +129,18 @@ const LeftWidget = observer(
|
|||||||
selectedLanguage === "ru"
|
selectedLanguage === "ru"
|
||||||
? sightArticles.get(leftArticle + "_" + selectedLanguage)
|
? sightArticles.get(leftArticle + "_" + selectedLanguage)
|
||||||
: selectedLanguage === "en"
|
: selectedLanguage === "en"
|
||||||
? sightArticlesEn.get(leftArticle + "_" + selectedLanguage)
|
? sightArticlesEn.get(leftArticle + "_" + selectedLanguage)
|
||||||
: sightArticlesZh.get(leftArticle + "_" + selectedLanguage);
|
: sightArticlesZh.get(leftArticle + "_" + selectedLanguage);
|
||||||
|
|
||||||
if (!leftArticleData?.media?.length) return;
|
|
||||||
|
|
||||||
const media = await ContentAPI.getMediaPreview(
|
const media = await ContentAPI.getMediaPreview(
|
||||||
leftArticleData.media[0].id,
|
leftArticleData.media[0].id,
|
||||||
selectedLanguage,
|
selectedLanguage
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
mediaPath: media.path,
|
mediaPath: media.path,
|
||||||
mediaType: media.type,
|
mediaType: media.type,
|
||||||
title: leftArticleData.heading,
|
title: sight.short_name || sight.name || leftArticleData.heading,
|
||||||
text: leftArticleData.body,
|
text: leftArticleData.body,
|
||||||
address: sight.address,
|
address: sight.address,
|
||||||
};
|
};
|
||||||
@@ -129,7 +178,7 @@ const LeftWidget = observer(
|
|||||||
setIsImageLoaded(false);
|
setIsImageLoaded(false);
|
||||||
console.error(
|
console.error(
|
||||||
"Ошибка загрузки изображения для достопримечательности:",
|
"Ошибка загрузки изображения для достопримечательности:",
|
||||||
selectedSightId,
|
selectedSightId
|
||||||
);
|
);
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -159,7 +208,7 @@ const LeftWidget = observer(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={widgetRef} style={widgetTransformStyle} className="left-widget" lang={selectedLanguage}>
|
<div ref={widgetRef} style={widgetTransformStyle} className="left-widget">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div>Загрузка информации...</div>
|
<div>Загрузка информации...</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
@@ -185,11 +234,9 @@ const LeftWidget = observer(
|
|||||||
<div className="left-widget-address">
|
<div className="left-widget-address">
|
||||||
{selectedSightData.address}
|
{selectedSightData.address}
|
||||||
</div>
|
</div>
|
||||||
<TouchableLayout ref={layoutRef} className="left-widget-text-scroll">
|
<div ref={textRef} className="left-widget-text">
|
||||||
<div className="left-widget-text">
|
{selectedSightData.text}
|
||||||
<ReactMarkdownComponent value={selectedSightData.text} />
|
</div>
|
||||||
</div>
|
|
||||||
</TouchableLayout>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (isVisible || selectedSightData) && !isLoading ? (
|
) : (isVisible || selectedSightData) && !isLoading ? (
|
||||||
@@ -197,13 +244,13 @@ const LeftWidget = observer(
|
|||||||
{selectedLanguage === "ru"
|
{selectedLanguage === "ru"
|
||||||
? "Выберите достопримечательность для просмотра деталей."
|
? "Выберите достопримечательность для просмотра деталей."
|
||||||
: selectedLanguage === "zh"
|
: selectedLanguage === "zh"
|
||||||
? "选择一个地标来查看详细信息。"
|
? "选择一个地标来查看详细信息。"
|
||||||
: "Select a landmark to view details."}
|
: "Select a landmark to view details."}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export default LeftWidget;
|
export default LeftWidget;
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import "../../styles/SideMenu.css";
|
|||||||
import AppealWidget from "../widgets/AppealWidget";
|
import AppealWidget from "../widgets/AppealWidget";
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
import gouvermentImage from "../../assets/images/test-image.png";
|
||||||
import sideMenuPhoto from "/side-menu-photo.png";
|
import sideMenuPhoto from "/side-menu-photo.png";
|
||||||
import RouteWidget from "../widgets/RouteWidget";
|
import RouteWidget from "../widgets/RouteWidget";
|
||||||
import WeatherWidget from "../WeatherWidget";
|
|
||||||
import ContentAPI from "../../api/content/content.api";
|
import ContentAPI from "../../api/content/content.api";
|
||||||
import { useGeolocationStore, useColorStore } from "../../stores";
|
import { useGeolocationStore, useColorStore } from "../../stores";
|
||||||
import "../../styles/LeftWidget.css";
|
import "../../styles/LeftWidget.css";
|
||||||
@@ -17,6 +17,7 @@ import { getMediaUrl } from "../../api/apiConfig";
|
|||||||
const SideMenu = observer(({ onMenuToggle }) => {
|
const SideMenu = observer(({ onMenuToggle }) => {
|
||||||
const {
|
const {
|
||||||
carrier,
|
carrier,
|
||||||
|
city,
|
||||||
route,
|
route,
|
||||||
getArticle,
|
getArticle,
|
||||||
sightArticles,
|
sightArticles,
|
||||||
@@ -31,7 +32,6 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
const [isStationOpen, openStation] = useState(false);
|
const [isStationOpen, openStation] = useState(false);
|
||||||
const [isWidgetOpen, openWidget] = useState(false);
|
const [isWidgetOpen, openWidget] = useState(false);
|
||||||
const [isWeatherVisible, setIsWeatherVisible] = useState(true);
|
const [isWeatherVisible, setIsWeatherVisible] = useState(true);
|
||||||
const [isPreviewMediaError, setIsPreviewMediaError] = useState(false);
|
|
||||||
const [isLeftWidgetVisible, setIsLeftWidgetVisible] = useState(false);
|
const [isLeftWidgetVisible, setIsLeftWidgetVisible] = useState(false);
|
||||||
const [localSelectedSightId, setLocalSelectedSightId] = useState(null);
|
const [localSelectedSightId, setLocalSelectedSightId] = useState(null);
|
||||||
const [isSightsAnimating, setIsSightsAnimating] = useState(false);
|
const [isSightsAnimating, setIsSightsAnimating] = useState(false);
|
||||||
@@ -85,7 +85,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
let scrollTarget = null;
|
let scrollTarget = null;
|
||||||
const targetElement = document.elementFromPoint(
|
const targetElement = document.elementFromPoint(
|
||||||
touch.clientX,
|
touch.clientX,
|
||||||
touch.clientY,
|
touch.clientY
|
||||||
);
|
);
|
||||||
|
|
||||||
// Определяем, над каким из скролл-контейнеров находится палец
|
// Определяем, над каким из скролл-контейнеров находится палец
|
||||||
@@ -185,12 +185,27 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
}
|
}
|
||||||
}, [isLeftWidgetVisible]);
|
}, [isLeftWidgetVisible]);
|
||||||
|
|
||||||
|
const [designData, setDesignData] = useState(null);
|
||||||
const [routeData, setRouteData] = useState(null);
|
const [routeData, setRouteData] = useState(null);
|
||||||
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
|
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsPreviewMediaError(false);
|
async function fetchCarrierAndCrest() {
|
||||||
}, [carrier?.preview_media]);
|
try {
|
||||||
|
const carrierPath = carrier.logo ? getMediaUrl(carrier.logo) : null;
|
||||||
|
const cityPath = city.arms ? getMediaUrl(city.arms) : null;
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
carrierPath: carrierPath,
|
||||||
|
creastPath: cityPath,
|
||||||
|
};
|
||||||
|
setDesignData(response);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Ошибка в получении данных герба и перевозчика:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchCarrierAndCrest();
|
||||||
|
}, [contextData?.routeId, isGeolocationLoading, selectedLanguage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isGeolocationLoading || !contextData?.routeId) {
|
if (isGeolocationLoading || !contextData?.routeId) {
|
||||||
@@ -248,19 +263,15 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMenuToggleRef = useRef(handleMenuToggle);
|
|
||||||
handleMenuToggleRef.current = handleMenuToggle;
|
|
||||||
|
|
||||||
const isMenuOpenRef = useRef(isMenuOpen);
|
|
||||||
isMenuOpenRef.current = isMenuOpen;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Автоматическое закрытие сайд-меню после 45 секунд бездействия
|
||||||
let idleSeconds = 0;
|
let idleSeconds = 0;
|
||||||
|
|
||||||
const checkIdle = () => {
|
const checkIdle = () => {
|
||||||
idleSeconds += 1;
|
idleSeconds += 1;
|
||||||
if (idleSeconds >= 60 && isMenuOpenRef.current) {
|
|
||||||
handleMenuToggleRef.current(false);
|
if (idleSeconds >= 45 && isMenuOpen) {
|
||||||
|
handleMenuToggle(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -289,48 +300,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
window.removeEventListener(event, resetIdle);
|
window.removeEventListener(event, resetIdle);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}, []);
|
}, [isMenuOpen, handleMenuToggle]);
|
||||||
|
|
||||||
// Автоматическое скрытие левого виджета через 60 секунд бездействия
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLeftWidgetVisible) return;
|
|
||||||
|
|
||||||
let idleSeconds = 0;
|
|
||||||
|
|
||||||
const checkIdle = () => {
|
|
||||||
idleSeconds += 1;
|
|
||||||
if (idleSeconds >= 60) {
|
|
||||||
setIsLeftWidgetVisible(false);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsLeftWidgetOpen(false);
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const intervalId = setInterval(checkIdle, 1000);
|
|
||||||
|
|
||||||
const resetIdle = () => {
|
|
||||||
idleSeconds = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const events = [
|
|
||||||
"mousedown",
|
|
||||||
"mousemove",
|
|
||||||
"keypress",
|
|
||||||
"scroll",
|
|
||||||
"touchstart",
|
|
||||||
"click",
|
|
||||||
];
|
|
||||||
|
|
||||||
events.forEach((event) =>
|
|
||||||
window.addEventListener(event, resetIdle, { passive: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
events.forEach((event) => window.removeEventListener(event, resetIdle));
|
|
||||||
};
|
|
||||||
}, [isLeftWidgetVisible, setIsLeftWidgetOpen]);
|
|
||||||
|
|
||||||
// Закрываем и открываем список достопримечательностей при изменении сортировки
|
// Закрываем и открываем список достопримечательностей при изменении сортировки
|
||||||
const prevSortingByRef = useRef(sortingBy);
|
const prevSortingByRef = useRef(sortingBy);
|
||||||
@@ -387,7 +357,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
background:
|
background:
|
||||||
isSightsOpen || isStationOpen
|
isSightsOpen || isStationOpen
|
||||||
? `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(var(--carrier-left-rgb, 76, 175, 75), 0.4)`
|
? `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(76, 175, 75, 0.4)`
|
||||||
: undefined,
|
: undefined,
|
||||||
backdropFilter:
|
backdropFilter:
|
||||||
isSightsOpen || isStationOpen ? "blur(10px)" : undefined,
|
isSightsOpen || isStationOpen ? "blur(10px)" : undefined,
|
||||||
@@ -399,6 +369,16 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
"background 0.3s ease, backdrop-filter 0.3s ease, box-shadow 0.3s ease",
|
"background 0.3s ease, backdrop-filter 0.3s ease, box-shadow 0.3s ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{designData?.creastPath && (
|
||||||
|
<img
|
||||||
|
className="side-menu-crest"
|
||||||
|
src={designData?.creastPath}
|
||||||
|
alt="Герб"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{carrier?.slogan && (
|
||||||
|
<div className="side-menu-label">{carrier.slogan}</div>
|
||||||
|
)}
|
||||||
{shouldRenderSights && (
|
{shouldRenderSights && (
|
||||||
<SightsList
|
<SightsList
|
||||||
ref={sightsListRef}
|
ref={sightsListRef}
|
||||||
@@ -464,16 +444,14 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
}}
|
}}
|
||||||
className="appeal-button"
|
className="appeal-button"
|
||||||
>
|
>
|
||||||
{route?.button_text
|
{selectedLanguage == "ru"
|
||||||
? route.button_text
|
? "Обращение губернатора"
|
||||||
: selectedLanguage == "ru"
|
: selectedLanguage == "zh"
|
||||||
? "Обращение губернатора"
|
? "州长致辞"
|
||||||
: selectedLanguage == "zh"
|
: "Governor's appeal"}
|
||||||
? "州长致辞"
|
|
||||||
: "Governor's appeal"}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="side-menu-buttons" style={{ marginTop: route?.governor_appeal > 0 ? '40px' : '260px' }}>
|
<div className="side-menu-buttons">
|
||||||
<div
|
<div
|
||||||
onPointerUp={() => {
|
onPointerUp={() => {
|
||||||
if (!isSightsOpen) {
|
if (!isSightsOpen) {
|
||||||
@@ -522,8 +500,8 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
{selectedLanguage == "ru"
|
{selectedLanguage == "ru"
|
||||||
? "Достопримечательности"
|
? "Достопримечательности"
|
||||||
: selectedLanguage == "zh"
|
: selectedLanguage == "zh"
|
||||||
? "景点"
|
? "景点"
|
||||||
: "Attractions"}
|
: "Attractions"}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onPointerUp={() => {
|
onPointerUp={() => {
|
||||||
@@ -570,8 +548,8 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
{selectedLanguage == "ru"
|
{selectedLanguage == "ru"
|
||||||
? "Остановки"
|
? "Остановки"
|
||||||
: selectedLanguage == "zh"
|
: selectedLanguage == "zh"
|
||||||
? "车站"
|
? "车站"
|
||||||
: "Stations"}
|
: "Stations"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="side-menu-tag">
|
<div className="side-menu-tag">
|
||||||
@@ -582,62 +560,44 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
: "#EveryoneOnTheWay"} */}
|
: "#EveryoneOnTheWay"} */}
|
||||||
</div>
|
</div>
|
||||||
<div className="side-menu-bottom-section">
|
<div className="side-menu-bottom-section">
|
||||||
<img
|
<div className="side-menu-carrier-block">
|
||||||
className="side-menu-bottom-photo"
|
{designData?.carrierPath && (
|
||||||
src={
|
<img
|
||||||
carrier?.preview_media && !isPreviewMediaError
|
className="carrier-logo"
|
||||||
? getMediaUrl(carrier.preview_media)
|
src={designData?.carrierPath}
|
||||||
: sideMenuPhoto
|
alt="ГЭТ - Электротранспорт Санкт-Петербурга"
|
||||||
}
|
/>
|
||||||
onError={() => setIsPreviewMediaError(true)}
|
)}
|
||||||
alt=""
|
{carrier?.full_name && (
|
||||||
/>
|
<div className="side-menu-slogan">{carrier.short_name}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<img className="side-menu-bottom-photo" src={sideMenuPhoto} alt="" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="side-menu-control-panel">
|
<div className="side-menu-control-panel">
|
||||||
<RouteWidget />
|
<RouteWidget />
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
transform:
|
|
||||||
isWeatherVisible && !isGovernorWidgetOpen
|
|
||||||
? "translateX(0)"
|
|
||||||
: "translateX(-200%)",
|
|
||||||
transition: "transform 1s ease",
|
|
||||||
opacity: isWeatherVisible && !isGovernorWidgetOpen ? 1 : 0,
|
|
||||||
pointerEvents:
|
|
||||||
isWeatherVisible && !isGovernorWidgetOpen ? "auto" : "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<WeatherWidget />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AppealWidget
|
<AppealWidget
|
||||||
widgetImgPath={(() => {
|
widgetImgPath={gouvermentImage}
|
||||||
const m = sightArticles.get(route?.governor_appeal + "_ru")?.media;
|
|
||||||
const mediaId = Array.isArray(m) ? m[0]?.id : m?.id;
|
|
||||||
return mediaId ? ContentAPI.getMediaPath(mediaId) : undefined;
|
|
||||||
})()}
|
|
||||||
isOpen={isWidgetOpen}
|
|
||||||
style={{
|
style={{
|
||||||
transform: isWidgetOpen ? "translateX(0)" : "translateX(-200%)",
|
transform: isWidgetOpen ? "translateX(0)" : "translateX(-200%)",
|
||||||
transition: "transform 0.5s ease",
|
transition: "transform 0.5s ease",
|
||||||
zIndex: -1,
|
zIndex: -1,
|
||||||
pointerEvents: isWidgetOpen ? "auto" : "none",
|
|
||||||
}}
|
}}
|
||||||
widgetLabel={
|
widgetLabel={
|
||||||
selectedLanguage == "ru"
|
selectedLanguage == "ru"
|
||||||
? sightArticles.get(route?.governor_appeal + "_ru")?.heading
|
? sightArticles.get(route?.governor_appeal + "_ru")?.heading
|
||||||
: selectedLanguage == "zh"
|
: selectedLanguage == "zh"
|
||||||
? sightArticlesZh.get(route?.governor_appeal + "_zh")?.heading
|
? sightArticlesZh.get(route?.governor_appeal + "_zh")?.heading
|
||||||
: sightArticlesEn.get(route?.governor_appeal + "_en")?.heading
|
: sightArticlesEn.get(route?.governor_appeal + "_en")?.heading
|
||||||
}
|
}
|
||||||
widgetText={
|
widgetText={
|
||||||
selectedLanguage == "ru"
|
selectedLanguage == "ru"
|
||||||
? sightArticles.get(route?.governor_appeal + "_ru")?.body
|
? sightArticles.get(route?.governor_appeal + "_ru")?.body
|
||||||
: selectedLanguage == "zh"
|
: selectedLanguage == "zh"
|
||||||
? sightArticlesZh.get(route?.governor_appeal + "_zh")?.body
|
? sightArticlesZh.get(route?.governor_appeal + "_zh")?.body
|
||||||
: sightArticlesEn.get(route?.governor_appeal + "_en")?.body
|
: sightArticlesEn.get(route?.governor_appeal + "_en")?.body
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const StationItem = ({
|
|||||||
className="side-menu-sight"
|
className="side-menu-sight"
|
||||||
onPointerDown={(e) => handlePointerDown(e, station.id)}
|
onPointerDown={(e) => handlePointerDown(e, station.id)}
|
||||||
onPointerUp={(e) => handlePointerUp(e, station.id, handleStationClick)}
|
onPointerUp={(e) => handlePointerUp(e, station.id, handleStationClick)}
|
||||||
|
title={station.name}
|
||||||
>
|
>
|
||||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||||
{station.name}
|
{station.name}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { useGeolocationStore } from "../../stores";
|
|||||||
import { apiStore } from "../../api/ApiStore/store";
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
import { useClickDetection } from "../../hooks/useClickDetection";
|
import { useClickDetection } from "../../hooks/useClickDetection";
|
||||||
import { TouchableLayout } from "../TouchableLayout";
|
import { TouchableLayout } from "../TouchableLayout";
|
||||||
import Collapsible from "./Collapsible";
|
|
||||||
import { getMediaUrl } from "../../api/apiConfig";
|
import { getMediaUrl } from "../../api/apiConfig";
|
||||||
import stationIcon from "../../assets/transport-icons/station.svg";
|
import stationIcon from "../../assets/transport-icons/station.svg";
|
||||||
|
|
||||||
@@ -39,8 +38,25 @@ const SightItem = ({
|
|||||||
const [shouldAnimate, setShouldAnimate] = useState(false);
|
const [shouldAnimate, setShouldAnimate] = useState(false);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
// Получаем название для отображения
|
||||||
const getSightName = () => {
|
const getSightName = () => {
|
||||||
return sight.short_name || sight.name;
|
// Если есть короткое название, используем его
|
||||||
|
if (sight.short_name) {
|
||||||
|
return sight.short_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sight.left_article) {
|
||||||
|
return sight.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftArticleData =
|
||||||
|
selectedLanguage === "ru"
|
||||||
|
? sightArticles.get(sight.left_article + "_ru")
|
||||||
|
: selectedLanguage === "en"
|
||||||
|
? sightArticlesEn.get(sight.left_article + "_en")
|
||||||
|
: sightArticlesZh.get(sight.left_article + "_zh");
|
||||||
|
|
||||||
|
return leftArticleData?.heading || sight.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sightName = getSightName();
|
const sightName = getSightName();
|
||||||
@@ -79,48 +95,28 @@ const SightItem = ({
|
|||||||
const cacheKey = `${sight.id}_${selectedLanguage}`;
|
const cacheKey = `${sight.id}_${selectedLanguage}`;
|
||||||
const stations = sightStationsCache.get(cacheKey) || [];
|
const stations = sightStationsCache.get(cacheKey) || [];
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (localSelectedSightId !== sight.id) {
|
|
||||||
setIsExpanded(false);
|
|
||||||
}
|
|
||||||
}, [localSelectedSightId, sight.id]);
|
|
||||||
|
|
||||||
// Логирование при открытии списка станций для достопримечательности (только один раз)
|
|
||||||
const wasExpandedRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
// Выводим логи только когда список открывается (переход из закрытого в открытое состояние)
|
|
||||||
if (isExpanded && !wasExpandedRef.current && stations.length > 0) {
|
|
||||||
wasExpandedRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сбрасываем флаг когда список закрывается
|
|
||||||
if (!isExpanded) {
|
|
||||||
wasExpandedRef.current = false;
|
|
||||||
}
|
|
||||||
}, [isExpanded, sight.id, selectedLanguage, stations.length]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div>
|
||||||
className={`side-menu-sight-wrapper ${
|
|
||||||
localSelectedSightId === sight.id ? "side-menu-sight-selected-wrapper" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
id={`sight-${sight.id}`}
|
id={`sight-${sight.id}`}
|
||||||
onPointerDown={(e) => handlePointerDown(e, sight.id)}
|
onPointerDown={(e) => handlePointerDown(e, sight.id)}
|
||||||
onPointerUp={(e) => handlePointerUp(e, sight.id, handleClick)}
|
onPointerUp={(e) => handlePointerUp(e, sight.id, handleClick)}
|
||||||
className="side-menu-sight pointer"
|
className={`side-menu-sight pointer ${
|
||||||
|
localSelectedSightId === sight.id ? "selected" : ""
|
||||||
|
}`}
|
||||||
|
title={sightName}
|
||||||
>
|
>
|
||||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||||
{sightName}
|
{sightName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Collapsible className="side-menu-sight-transfer-list" open={isExpanded}>
|
<div
|
||||||
|
className={`side-menu-sight-transfer-list ${isExpanded ? "open" : ""}`}
|
||||||
|
>
|
||||||
{stations.length > 0 ? (
|
{stations.length > 0 ? (
|
||||||
stations.map((station, index) => {
|
stations.map((station, index) => {
|
||||||
let isMediaIdEmptyResult = isMediaIdEmpty(station.icon);
|
const iconSrc = isMediaIdEmpty(station.icon)
|
||||||
const iconSrc = isMediaIdEmptyResult
|
|
||||||
? stationIcon
|
? stationIcon
|
||||||
: getMediaUrl(station.icon);
|
: getMediaUrl(station.icon);
|
||||||
|
|
||||||
@@ -161,7 +157,7 @@ const SightItem = ({
|
|||||||
: "No stations"}
|
: "No stations"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Collapsible>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -237,8 +233,24 @@ const SightsList = observer(
|
|||||||
: selectedLanguage === "en"
|
: selectedLanguage === "en"
|
||||||
? routeSightsEn
|
? routeSightsEn
|
||||||
: routeSightsZh;
|
: routeSightsZh;
|
||||||
|
// Функция для получения названия для сортировки
|
||||||
const getSightNameForSort = (sight) => {
|
const getSightNameForSort = (sight) => {
|
||||||
return (sight.short_name || sight.name).trim();
|
if (sight.short_name) {
|
||||||
|
return sight.short_name.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sight.left_article) {
|
||||||
|
return sight.name.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftArticleData =
|
||||||
|
selectedLanguage === "ru"
|
||||||
|
? sightArticles.get(sight.left_article + "_ru")
|
||||||
|
: selectedLanguage === "en"
|
||||||
|
? sightArticlesEn.get(sight.left_article + "_en")
|
||||||
|
: sightArticlesZh.get(sight.left_article + "_zh");
|
||||||
|
|
||||||
|
return (leftArticleData?.heading || sight.name).trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedSights = [...(sights || [])].sort((a, b) => {
|
const sortedSights = [...(sights || [])].sort((a, b) => {
|
||||||
@@ -337,17 +349,24 @@ const SightsList = observer(
|
|||||||
}, [localSelectedSightId, sightList, className]);
|
}, [localSelectedSightId, sightList, className]);
|
||||||
|
|
||||||
const handleSightClick = (sightId) => {
|
const handleSightClick = (sightId) => {
|
||||||
// Если кликнули на уже выбранную достопримечательность — закрываем виджет и сбрасываем цвет
|
|
||||||
if (isLeftWidgetOpen && localSelectedSightId === sightId) {
|
|
||||||
setLocalSelectedSightId(null);
|
|
||||||
setIsLeftWidgetOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Открываем левый виджет
|
// Открываем левый виджет
|
||||||
setLocalSelectedSightId(sightId);
|
if (isLeftWidgetOpen && localSelectedSightId !== sightId) {
|
||||||
setIsLeftWidgetOpen(true);
|
setLocalSelectedSightId(sightId);
|
||||||
if (onSightSelected) {
|
setIsLeftWidgetOpen(true);
|
||||||
onSightSelected(sightId);
|
if (onSightSelected) {
|
||||||
|
onSightSelected(sightId);
|
||||||
|
}
|
||||||
|
} else if (!isLeftWidgetOpen) {
|
||||||
|
setLocalSelectedSightId(sightId);
|
||||||
|
setIsLeftWidgetOpen(true);
|
||||||
|
if (onSightSelected) {
|
||||||
|
onSightSelected(sightId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Если виджет уже открыт для этой достопримечательности, просто обновляем список остановок
|
||||||
|
if (onSightSelected) {
|
||||||
|
onSightSelected(sightId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ const SightItem = ({
|
|||||||
const textRef = useRef(null);
|
const textRef = useRef(null);
|
||||||
const [shouldAnimate, setShouldAnimate] = useState(false);
|
const [shouldAnimate, setShouldAnimate] = useState(false);
|
||||||
|
|
||||||
// Получаем название из left_article или используем short_name/name
|
// Получаем название из left_article
|
||||||
const getSightName = () => {
|
const getSightName = () => {
|
||||||
if (!sight.left_article) {
|
if (!sight.left_article) {
|
||||||
return sight.short_name || sight.name;
|
return sight.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftArticleData =
|
const leftArticleData =
|
||||||
@@ -41,7 +41,7 @@ const SightItem = ({
|
|||||||
? sightArticlesEn.get(sight.left_article + "_en")
|
? sightArticlesEn.get(sight.left_article + "_en")
|
||||||
: sightArticlesZh.get(sight.left_article + "_zh");
|
: sightArticlesZh.get(sight.left_article + "_zh");
|
||||||
|
|
||||||
return leftArticleData?.heading || sight.short_name || sight.name;
|
return leftArticleData?.heading || sight.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sightName = getSightName();
|
const sightName = getSightName();
|
||||||
@@ -70,6 +70,7 @@ const SightItem = ({
|
|||||||
className={`side-menu-sight pointer ${
|
className={`side-menu-sight pointer ${
|
||||||
localSelectedSightId === sight.id ? "selected" : ""
|
localSelectedSightId === sight.id ? "selected" : ""
|
||||||
}`}
|
}`}
|
||||||
|
title={sightName}
|
||||||
>
|
>
|
||||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||||
{sightName}
|
{sightName}
|
||||||
@@ -119,9 +120,10 @@ const StationSightsList = observer(
|
|||||||
selectedLanguage
|
selectedLanguage
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Функция для получения названия из left_article
|
||||||
const getSightNameForSort = (sight) => {
|
const getSightNameForSort = (sight) => {
|
||||||
if (!sight.left_article) {
|
if (!sight.left_article) {
|
||||||
return (sight.short_name || sight.name).trim();
|
return sight.name.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftArticleData =
|
const leftArticleData =
|
||||||
@@ -131,7 +133,7 @@ const StationSightsList = observer(
|
|||||||
? sightArticlesEn.get(sight.left_article + "_en")
|
? sightArticlesEn.get(sight.left_article + "_en")
|
||||||
: sightArticlesZh.get(sight.left_article + "_zh");
|
: sightArticlesZh.get(sight.left_article + "_zh");
|
||||||
|
|
||||||
return (leftArticleData?.heading || sight.short_name || sight.name).trim();
|
return (leftArticleData?.heading || sight.name).trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedSights = [...(response || [])].sort((a, b) => {
|
const sortedSights = [...(response || [])].sort((a, b) => {
|
||||||
@@ -309,4 +311,3 @@ const StationSightsList = observer(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default StationSightsList;
|
export default StationSightsList;
|
||||||
nSightsList;
|
|
||||||
|
|||||||
@@ -10,43 +10,6 @@ import { useGeolocationStore } from "../../stores";
|
|||||||
import { apiStore } from "../../api/ApiStore/store";
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
import { useClickDetection } from "../../hooks/useClickDetection";
|
import { useClickDetection } from "../../hooks/useClickDetection";
|
||||||
import { TouchableLayout } from "../TouchableLayout";
|
import { TouchableLayout } from "../TouchableLayout";
|
||||||
import Collapsible from "./Collapsible";
|
|
||||||
|
|
||||||
const SightTransferItem = ({ name, style, onPointerUp }) => {
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
const textRef = useRef(null);
|
|
||||||
const [shouldAnimate, setShouldAnimate] = useState(false);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const checkWidth = () => {
|
|
||||||
if (containerRef.current && textRef.current) {
|
|
||||||
const containerWidth = containerRef.current.offsetWidth;
|
|
||||||
const textWidth = textRef.current.scrollWidth;
|
|
||||||
const shouldAnimateValue = textWidth > containerWidth;
|
|
||||||
setShouldAnimate(shouldAnimateValue);
|
|
||||||
if (shouldAnimateValue) {
|
|
||||||
containerRef.current.style.setProperty("--container-width", `${containerWidth}px`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
checkWidth();
|
|
||||||
window.addEventListener("resize", checkWidth);
|
|
||||||
return () => window.removeEventListener("resize", checkWidth);
|
|
||||||
}, [name]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="side-menu-sight-transfer pointer"
|
|
||||||
style={style}
|
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
>
|
|
||||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const StationItem = ({
|
const StationItem = ({
|
||||||
station,
|
station,
|
||||||
@@ -93,15 +56,26 @@ const StationItem = ({
|
|||||||
const sights = stationSightsCache.get(cacheKey) || [];
|
const sights = stationSightsCache.get(cacheKey) || [];
|
||||||
|
|
||||||
const getSightName = (sight) => {
|
const getSightName = (sight) => {
|
||||||
return sight.short_name || sight.name;
|
if (sight.short_name) {
|
||||||
|
return sight.short_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sight.left_article) {
|
||||||
|
return sight.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftArticleData =
|
||||||
|
selectedLanguage === "ru"
|
||||||
|
? sightArticles.get(sight.left_article + "_ru")
|
||||||
|
: selectedLanguage === "en"
|
||||||
|
? sightArticlesEn.get(sight.left_article + "_en")
|
||||||
|
: sightArticlesZh.get(sight.left_article + "_zh");
|
||||||
|
|
||||||
|
return leftArticleData?.heading || sight.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div>
|
||||||
className={`side-menu-sight-wrapper ${
|
|
||||||
selectedStationId === station.id ? "side-menu-sight-selected-wrapper" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="side-menu-sight"
|
className="side-menu-sight"
|
||||||
@@ -114,20 +88,22 @@ const StationItem = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
title={station.name}
|
||||||
>
|
>
|
||||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||||
{station.name}
|
{station.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Collapsible
|
<div
|
||||||
className="side-menu-sight-transfer-list"
|
className={`side-menu-sight-transfer-list ${
|
||||||
open={selectedStationId === station.id}
|
selectedStationId === station.id ? "open" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{sights.length > 0 ? (
|
{sights.length > 0 ? (
|
||||||
sights.map((sight, index) => (
|
sights.map((sight, index) => (
|
||||||
<SightTransferItem
|
<div
|
||||||
key={sight.id}
|
key={sight.id}
|
||||||
name={getSightName(sight)}
|
className="side-menu-sight-transfer pointer"
|
||||||
style={{
|
style={{
|
||||||
borderBottom:
|
borderBottom:
|
||||||
index < sights.length - 1
|
index < sights.length - 1
|
||||||
@@ -139,11 +115,19 @@ const StationItem = ({
|
|||||||
onPointerUp={(e) => {
|
onPointerUp={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (onSightClick) {
|
if (onSightClick) {
|
||||||
const elementRect = e.currentTarget.getBoundingClientRect();
|
// Вычисляем позицию элемента для правильного позиционирования левого виджета
|
||||||
onSightClick(sight.id, elementRect.top);
|
const element = e.currentTarget;
|
||||||
|
const elementRect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Используем позицию элемента относительно viewport (elementRect.top)
|
||||||
|
// чтобы верхняя граница виджета совпадала с верхней границей элемента
|
||||||
|
const elementTop = elementRect.top;
|
||||||
|
onSightClick(sight.id, elementTop);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{getSightName(sight)}
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="side-menu-sight-transfer-empty">
|
<div className="side-menu-sight-transfer-empty">
|
||||||
@@ -154,7 +138,7 @@ const StationItem = ({
|
|||||||
: "No sights"}
|
: "No sights"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Collapsible>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,50 +1,13 @@
|
|||||||
import { useRef, useEffect } from "react";
|
import '../../styles/AppealWidget.css'
|
||||||
import "../../styles/AppealWidget.css";
|
|
||||||
import { TouchableLayout } from "../TouchableLayout";
|
|
||||||
import { ReactMarkdownComponent } from "../ReactMarkdown";
|
|
||||||
|
|
||||||
function AppealWidget({
|
function AppealWidget({widgetImgPath, widgetLabel, widgetText, style}) {
|
||||||
widgetImgPath,
|
return (
|
||||||
widgetLabel,
|
<div style={style} className='dynamic-widget'>
|
||||||
widgetText,
|
<img className='dynamic-widget-image' src={widgetImgPath} />
|
||||||
style,
|
<div className='dynamic-widget-label'>{widgetLabel}</div>
|
||||||
isOpen,
|
<div className='dynamic-widget-text'>{widgetText}</div>
|
||||||
}) {
|
|
||||||
const stopProp = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
const layoutRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && layoutRef.current) {
|
|
||||||
const scrollable = layoutRef.current.querySelector(".scrollable");
|
|
||||||
if (scrollable) scrollable.scrollTop = 0;
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={style}
|
|
||||||
className="dynamic-widget"
|
|
||||||
onPointerDown={stopProp}
|
|
||||||
onPointerMove={stopProp}
|
|
||||||
onPointerUp={stopProp}
|
|
||||||
>
|
|
||||||
{widgetImgPath && (
|
|
||||||
<img className="dynamic-widget-image" src={widgetImgPath} />
|
|
||||||
)}
|
|
||||||
<div className="dynamic-widget-label">{widgetLabel}</div>
|
|
||||||
<TouchableLayout
|
|
||||||
ref={layoutRef}
|
|
||||||
className="dynamic-widget-text-scroll"
|
|
||||||
>
|
|
||||||
<div className="dynamic-widget-text">
|
|
||||||
<ReactMarkdownComponent value={widgetText} />
|
|
||||||
</div>
|
</div>
|
||||||
</TouchableLayout>
|
);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AppealWidget;
|
export default AppealWidget
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { apiStore } from "../../api/ApiStore/store";
|
|||||||
const RouteWidget = observer(() => {
|
const RouteWidget = observer(() => {
|
||||||
const store = useGeolocationStore();
|
const store = useGeolocationStore();
|
||||||
const { contextData, isLoading, error, selectedLanguage } = store;
|
const { contextData, isLoading, error, selectedLanguage } = store;
|
||||||
const { routeStations, routeStationsEn, routeStationsZh, context } = apiStore;
|
const { routeStations, routeStationsEn, routeStationsZh, context, route } = apiStore;
|
||||||
const [startStation, setStartStation] = useState(null);
|
const [startStation, setStartStation] = useState(null);
|
||||||
const [startStationEn, setStartStationEn] = useState(null);
|
const [startStationEn, setStartStationEn] = useState(null);
|
||||||
const [endStation, setEndStation] = useState(null);
|
const [endStation, setEndStation] = useState(null);
|
||||||
@@ -64,13 +64,6 @@ const RouteWidget = observer(() => {
|
|||||||
}, [context?.endStopId, isLoading, selectedLanguage]);
|
}, [context?.endStopId, isLoading, selectedLanguage]);
|
||||||
|
|
||||||
const shouldAnimate = (text, maxLength) => text?.length > maxLength;
|
const shouldAnimate = (text, maxLength) => text?.length > maxLength;
|
||||||
const getNumberSizeClass = (text) => {
|
|
||||||
const length = text?.length || 0;
|
|
||||||
if (length <= 2) return "";
|
|
||||||
if (length === 3) return "route-widget-number--3";
|
|
||||||
if (length === 4) return "route-widget-number--4";
|
|
||||||
return "route-widget-number--5";
|
|
||||||
};
|
|
||||||
const getLabelSizeClass = (text) => {
|
const getLabelSizeClass = (text) => {
|
||||||
const length = text?.length || 0;
|
const length = text?.length || 0;
|
||||||
|
|
||||||
@@ -84,14 +77,15 @@ const RouteWidget = observer(() => {
|
|||||||
const routeZhSubtitle = `${startStationZh?.name} - ${endStationZh?.name}`;
|
const routeZhSubtitle = `${startStationZh?.name} - ${endStationZh?.name}`;
|
||||||
return (
|
return (
|
||||||
<div className="route-widget">
|
<div className="route-widget">
|
||||||
<div className={`route-widget-number ${getNumberSizeClass(context?.routeNumber)}`}>
|
<div className="route-widget-number">
|
||||||
{context?.routeNumber || "No number"}
|
{route?.route_sys_number || context?.routeNumber || ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="route-widget-content">
|
<div className="route-widget-content">
|
||||||
<div
|
<div
|
||||||
className={`route-widget-label ${
|
className={`route-widget-label ${
|
||||||
shouldAnimate(startStation?.name, 18) ? "marquee" : ""
|
shouldAnimate(startStation?.name, 18) ? "marquee" : ""
|
||||||
} ${getLabelSizeClass(startStation?.name)}`}
|
} ${getLabelSizeClass(startStation?.name)}`}
|
||||||
|
title={startStation?.name}
|
||||||
>
|
>
|
||||||
{startStation?.name}
|
{startStation?.name}
|
||||||
</div>
|
</div>
|
||||||
@@ -99,6 +93,7 @@ const RouteWidget = observer(() => {
|
|||||||
className={`route-widget-label ${
|
className={`route-widget-label ${
|
||||||
shouldAnimate(endStation?.name, 18) ? "marquee" : ""
|
shouldAnimate(endStation?.name, 18) ? "marquee" : ""
|
||||||
} ${getLabelSizeClass(endStation?.name)}`}
|
} ${getLabelSizeClass(endStation?.name)}`}
|
||||||
|
title={endStation?.name}
|
||||||
>
|
>
|
||||||
{endStation?.name}
|
{endStation?.name}
|
||||||
</div>
|
</div>
|
||||||
@@ -107,6 +102,7 @@ const RouteWidget = observer(() => {
|
|||||||
className={`route-widget-subtitle ${
|
className={`route-widget-subtitle ${
|
||||||
shouldAnimate(routeEnSubtitle, 50) ? "marquee" : ""
|
shouldAnimate(routeEnSubtitle, 50) ? "marquee" : ""
|
||||||
}`}
|
}`}
|
||||||
|
title={routeEnSubtitle}
|
||||||
>
|
>
|
||||||
{routeEnSubtitle}
|
{routeEnSubtitle}
|
||||||
</div>
|
</div>
|
||||||
@@ -116,6 +112,7 @@ const RouteWidget = observer(() => {
|
|||||||
className={`route-widget-subtitle ${
|
className={`route-widget-subtitle ${
|
||||||
shouldAnimate(routeZhSubtitle, 50) ? "marquee" : ""
|
shouldAnimate(routeZhSubtitle, 50) ? "marquee" : ""
|
||||||
}`}
|
}`}
|
||||||
|
title={routeZhSubtitle}
|
||||||
>
|
>
|
||||||
{routeZhSubtitle}
|
{routeZhSubtitle}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Canvas, useThree, useFrame } from "@react-three/fiber";
|
import { Canvas, useThree } from "@react-three/fiber";
|
||||||
import { OrbitControls, Center, useGLTF } from "@react-three/drei";
|
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
|
||||||
import React, { useEffect, useRef, Suspense, useCallback } from "react";
|
import React, { useEffect, Suspense } from "react";
|
||||||
import { BACKGROUND_COLOR } from "../../assets/Constants";
|
import { BACKGROUND_COLOR } from "../../assets/Constants";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||||||
@@ -23,7 +23,6 @@ interface ThreeViewProps {
|
|||||||
const ZOOM_FACTOR = 1.2;
|
const ZOOM_FACTOR = 1.2;
|
||||||
const MIN_DISTANCE = 1;
|
const MIN_DISTANCE = 1;
|
||||||
const MAX_DISTANCE = 100;
|
const MAX_DISTANCE = 100;
|
||||||
const CAMERA_FOV = 40;
|
|
||||||
|
|
||||||
const TouchController = () => {
|
const TouchController = () => {
|
||||||
const { camera, controls, gl } = useThree();
|
const { camera, controls, gl } = useThree();
|
||||||
@@ -198,47 +197,6 @@ const AutoResize = () => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FitCamera = ({
|
|
||||||
groupRef,
|
|
||||||
onReady,
|
|
||||||
}: {
|
|
||||||
groupRef: React.RefObject<THREE.Group>;
|
|
||||||
onReady: () => void;
|
|
||||||
}) => {
|
|
||||||
const { camera, controls } = useThree();
|
|
||||||
const fitted = useRef(false);
|
|
||||||
|
|
||||||
useFrame(() => {
|
|
||||||
if (fitted.current) return;
|
|
||||||
const group = groupRef.current;
|
|
||||||
if (!group || group.children.length === 0) return;
|
|
||||||
|
|
||||||
const box = new THREE.Box3().setFromObject(group);
|
|
||||||
const sphere = new THREE.Sphere();
|
|
||||||
box.getBoundingSphere(sphere);
|
|
||||||
|
|
||||||
if (sphere.radius === 0) return;
|
|
||||||
|
|
||||||
const fov = THREE.MathUtils.degToRad(CAMERA_FOV);
|
|
||||||
const dist = sphere.radius / Math.sin(fov / 2);
|
|
||||||
|
|
||||||
camera.position.set(0, 0, dist);
|
|
||||||
camera.lookAt(0, 0, 0);
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
|
|
||||||
if (controls) {
|
|
||||||
const orbit = controls as unknown as OrbitControlsImpl;
|
|
||||||
orbit.target.set(0, 0, 0);
|
|
||||||
orbit.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
fitted.current = true;
|
|
||||||
onReady();
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Model = ({
|
const Model = ({
|
||||||
fileUrl,
|
fileUrl,
|
||||||
onLoad,
|
onLoad,
|
||||||
@@ -273,39 +231,24 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
|
|||||||
height = "100%",
|
height = "100%",
|
||||||
onLoad,
|
onLoad,
|
||||||
onError,
|
onError,
|
||||||
|
onAspectRatioCalculated,
|
||||||
controlRef,
|
controlRef,
|
||||||
}) => {
|
}) => {
|
||||||
const [isReady, setIsReady] = React.useState(false);
|
|
||||||
const groupRef = useRef<THREE.Group>(null!);
|
|
||||||
|
|
||||||
const handleReady = useCallback(() => {
|
|
||||||
setIsReady(true);
|
|
||||||
onLoad?.();
|
|
||||||
}, [onLoad]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width, height, position: "relative", overflow: "hidden" }}>
|
<div style={{ width, height, position: "relative", overflow: "hidden" }}>
|
||||||
{!isReady && (
|
|
||||||
<div style={{
|
|
||||||
position: "absolute", inset: 0,
|
|
||||||
backgroundColor: `#${BACKGROUND_COLOR.toString(16).padStart(6, "0")}`,
|
|
||||||
zIndex: 1,
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
<Canvas
|
<Canvas
|
||||||
gl={{
|
gl={{
|
||||||
antialias: true,
|
antialias: true,
|
||||||
toneMappingExposure: 1.5,
|
toneMappingExposure: 1.5,
|
||||||
outputColorSpace: THREE.SRGBColorSpace,
|
outputColorSpace: THREE.SRGBColorSpace,
|
||||||
}}
|
}}
|
||||||
camera={{ position: [0, 0, 50], fov: CAMERA_FOV }}
|
camera={{ position: [0, 0, 5], fov: 40 }}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
onError={(e: any) => onError?.(e.message)}
|
onError={(e) => onError?.(e.message)}
|
||||||
>
|
>
|
||||||
<AutoResize />
|
<AutoResize />
|
||||||
<TouchController />
|
<TouchController />
|
||||||
{controlRef && <ZoomController controlRef={controlRef} />}
|
{controlRef && <ZoomController controlRef={controlRef} />}
|
||||||
<FitCamera groupRef={groupRef} onReady={handleReady} />
|
|
||||||
<color attach="background" args={[BACKGROUND_COLOR]} />
|
<color attach="background" args={[BACKGROUND_COLOR]} />
|
||||||
<ambientLight intensity={0.8} />
|
<ambientLight intensity={0.8} />
|
||||||
<directionalLight position={[30, 30, 30]} intensity={1.2} />
|
<directionalLight position={[30, 30, 30]} intensity={1.2} />
|
||||||
@@ -323,18 +266,23 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
|
|||||||
<pointLight position={[0, 30, 0]} intensity={0.6} />
|
<pointLight position={[0, 30, 0]} intensity={0.6} />
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Center precise>
|
<Stage
|
||||||
<group ref={groupRef}>
|
environment={null}
|
||||||
<Model fileUrl={fileUrl} />
|
intensity={1}
|
||||||
</group>
|
contactShadow={false}
|
||||||
</Center>
|
shadows={false}
|
||||||
|
adjustCamera={true}
|
||||||
|
center={{ precise: true }}
|
||||||
|
>
|
||||||
|
<Model fileUrl={fileUrl} onLoad={onLoad} />
|
||||||
|
</Stage>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
makeDefault
|
makeDefault
|
||||||
enableZoom={true}
|
enableZoom={true}
|
||||||
enablePan={true}
|
enablePan={true}
|
||||||
target={[0, 0, 0]}
|
target={[50, 50, 50]}
|
||||||
minDistance={1}
|
minDistance={1}
|
||||||
maxDistance={100}
|
maxDistance={100}
|
||||||
enableDamping={true}
|
enableDamping={true}
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
.side-menu-sights-block {
|
.side-menu-sights-block {
|
||||||
height: calc(60%);
|
height: calc(60%);
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
margin-left: 0;
|
margin-left: 20px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding-right: 5px;
|
margin-right: 5px;
|
||||||
touch-action: none; /* Отключаем стандартные действия */
|
touch-action: none; /* Отключаем стандартные действия */
|
||||||
overscroll-behavior: contain; /* Предотвращаем прокрутку родительских элементов */
|
overscroll-behavior: contain; /* Предотвращаем прокрутку родительских элементов */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,33 @@ class CameraAnimationStore {
|
|||||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private calculateDistance(p1: CameraPosition, p2: CameraPosition): number {
|
||||||
|
return Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private isNearStation(
|
||||||
|
tramPos: CameraPosition,
|
||||||
|
stations: Station[]
|
||||||
|
): { isNear: boolean; distance: number } {
|
||||||
|
if (!stations || stations.length === 0)
|
||||||
|
return { isNear: false, distance: Infinity };
|
||||||
|
const threshold = 300; // Порог в координатах карты
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
for (const station of stations) {
|
||||||
|
const distance = this.calculateDistance(tramPos, {
|
||||||
|
x: station.longitude,
|
||||||
|
y: station.latitude,
|
||||||
|
});
|
||||||
|
minDistance = Math.min(minDistance, distance);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isNear: minDistance < threshold,
|
||||||
|
distance: minDistance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public setUpdateCallback(
|
public setUpdateCallback(
|
||||||
callback: ((pos: CameraPosition, zoom: number) => void) | null
|
callback: ((pos: CameraPosition, zoom: number) => void) | null
|
||||||
) {
|
) {
|
||||||
@@ -113,7 +140,7 @@ class CameraAnimationStore {
|
|||||||
public followTram(
|
public followTram(
|
||||||
tramMapPos: CameraPosition,
|
tramMapPos: CameraPosition,
|
||||||
screenCenter: CameraPosition,
|
screenCenter: CameraPosition,
|
||||||
_stations: Station[] = []
|
stations: Station[] = []
|
||||||
) {
|
) {
|
||||||
// Анимация начинается с текущего зума и плавно переходит к максимальному зуму
|
// Анимация начинается с текущего зума и плавно переходит к максимальному зуму
|
||||||
// для плавного приближения к желтой точке при слежении
|
// для плавного приближения к желтой точке при слежении
|
||||||
|
|||||||
@@ -1,45 +1,21 @@
|
|||||||
import { makeAutoObservable, runInAction } from "mobx";
|
import { makeAutoObservable, runInAction } from "mobx";
|
||||||
|
|
||||||
|
const COLOR_WHITE = { h: 151, s: 0, l: 100 };
|
||||||
|
const COLOR_GREEN = { h: 151, s: 100, l: 22 };
|
||||||
|
|
||||||
const TRANSITION_DURATION = 60000;
|
const TRANSITION_DURATION = 60000;
|
||||||
const TICK_INTERVAL = 100;
|
const TICK_INTERVAL = 100;
|
||||||
const TICK_STEP = TICK_INTERVAL / TRANSITION_DURATION;
|
const TICK_STEP = TICK_INTERVAL / TRANSITION_DURATION;
|
||||||
|
|
||||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
|
||||||
const clean = hex.trim().replace(/^#/, "");
|
|
||||||
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
|
|
||||||
if (full.length !== 6) return null;
|
|
||||||
return {
|
|
||||||
r: parseInt(full.slice(0, 2), 16),
|
|
||||||
g: parseInt(full.slice(2, 4), 16),
|
|
||||||
b: parseInt(full.slice(4, 6), 16),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function interpolateRgb(
|
|
||||||
from: { r: number; g: number; b: number },
|
|
||||||
to: { r: number; g: number; b: number },
|
|
||||||
t: number
|
|
||||||
): string {
|
|
||||||
const r = Math.round(from.r + (to.r - from.r) * t);
|
|
||||||
const g = Math.round(from.g + (to.g - from.g) * t);
|
|
||||||
const b = Math.round(from.b + (to.b - from.b) * t);
|
|
||||||
return `rgb(${r}, ${g}, ${b})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WHITE = { r: 255, g: 255, b: 255 };
|
|
||||||
const DEFAULT_MAIN = { r: 0, g: 111, b: 58 };
|
|
||||||
|
|
||||||
interface ColorStore {
|
interface ColorStore {
|
||||||
currentColor: string;
|
currentColor: string;
|
||||||
setCurrentColor: (color: string) => void;
|
setCurrentColor: (color: string) => void;
|
||||||
setMainColor: (hex: string) => void;
|
|
||||||
startColorAnimation: () => void;
|
startColorAnimation: () => void;
|
||||||
stopColorAnimation: () => void;
|
stopColorAnimation: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ColorStore implements ColorStore {
|
class ColorStore implements ColorStore {
|
||||||
currentColor: string = "#fff";
|
currentColor: string = "#fff";
|
||||||
private mainColor: { r: number; g: number; b: number } = DEFAULT_MAIN;
|
|
||||||
private progress: number = 0;
|
private progress: number = 0;
|
||||||
private direction: number = 1;
|
private direction: number = 1;
|
||||||
private tickInterval: ReturnType<typeof setInterval> | null = null;
|
private tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -52,12 +28,12 @@ class ColorStore implements ColorStore {
|
|||||||
this.currentColor = color;
|
this.currentColor = color;
|
||||||
};
|
};
|
||||||
|
|
||||||
setMainColor = (hex: string) => {
|
private interpolateColor(progress: number): string {
|
||||||
const parsed = hexToRgb(hex);
|
const h = Math.round(COLOR_WHITE.h + (COLOR_GREEN.h - COLOR_WHITE.h) * progress);
|
||||||
if (parsed) {
|
const s = Math.round(COLOR_WHITE.s + (COLOR_GREEN.s - COLOR_WHITE.s) * progress);
|
||||||
this.mainColor = parsed;
|
const l = Math.round(COLOR_WHITE.l + (COLOR_GREEN.l - COLOR_WHITE.l) * progress);
|
||||||
}
|
return `hsl(${h}, ${s}%, ${l}%)`;
|
||||||
};
|
}
|
||||||
|
|
||||||
startColorAnimation = () => {
|
startColorAnimation = () => {
|
||||||
if (this.tickInterval) return;
|
if (this.tickInterval) return;
|
||||||
@@ -74,7 +50,7 @@ class ColorStore implements ColorStore {
|
|||||||
this.direction = 1;
|
this.direction = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentColor = interpolateRgb(WHITE, this.mainColor, this.progress);
|
this.currentColor = this.interpolateColor(this.progress);
|
||||||
});
|
});
|
||||||
}, TICK_INTERVAL);
|
}, TICK_INTERVAL);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,124 +5,37 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 420px;
|
width: 420px;
|
||||||
max-height: calc(100vh - 150px - 98px);
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background:
|
background: linear-gradient(
|
||||||
linear-gradient(
|
|
||||||
114deg,
|
114deg,
|
||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
var(--carrier-left, #006f3a);
|
#006F3A;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dynamic-widget-image {
|
.dynamic-widget-image {
|
||||||
padding-top: 2px;
|
border-radius-top-left: 10px;
|
||||||
margin-left: 2px;
|
border-radius-top-right: 10px;
|
||||||
margin-right: 2px;
|
padding-top: 4px;
|
||||||
width: 416px;
|
margin-left: 4px;
|
||||||
border-radius: 10px 10px 0 0;
|
margin-right: 4px;
|
||||||
object-fit: cover;
|
width: 412px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dynamic-widget-label {
|
.dynamic-widget-label {
|
||||||
width: 100%;
|
width: 380px;
|
||||||
padding: 10px 20px;
|
margin-top: 29px;
|
||||||
box-sizing: border-box;
|
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #fff;
|
|
||||||
border-bottom: 1px solid var(--Glass-stroke, rgba(255, 255, 255, 0.8));
|
|
||||||
background:
|
|
||||||
linear-gradient(
|
|
||||||
180deg,
|
|
||||||
rgba(255, 255, 255, 0.2) 0%,
|
|
||||||
rgba(255, 255, 255, 0) 100%
|
|
||||||
),
|
|
||||||
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dynamic-widget-text-scroll.scrollable-container {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
align-self: stretch;
|
|
||||||
margin: 15px 20px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text-scroll .scrollable-viewport {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text-scroll .scrollable {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.dynamic-widget-text {
|
.dynamic-widget-text {
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
width: 380px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 135%;
|
line-height: 150%;
|
||||||
padding-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container {
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 135%;
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container p {
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 135%;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container h1,
|
|
||||||
.dynamic-widget-text .react-markdown-container h2,
|
|
||||||
.dynamic-widget-text .react-markdown-container h3,
|
|
||||||
.dynamic-widget-text .react-markdown-container h4,
|
|
||||||
.dynamic-widget-text .react-markdown-container h5,
|
|
||||||
.dynamic-widget-text .react-markdown-container h6 {
|
|
||||||
font-size: 18px;
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container ul,
|
|
||||||
.dynamic-widget-text .react-markdown-container ol {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container li {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container blockquote {
|
|
||||||
margin-top: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding-left: 12px;
|
|
||||||
border-left: 3px solid rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container img {
|
|
||||||
max-width: 100%;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-widget-text .react-markdown-container a {
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
var(--carrier-left, #006F3A);
|
#006F3A;
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
@@ -67,78 +67,17 @@
|
|||||||
line-height: 150%;
|
line-height: 150%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-widget-text-scroll.scrollable-container {
|
|
||||||
margin-top: 15px;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text-scroll .scrollable-viewport {
|
|
||||||
max-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text {
|
.left-widget-text {
|
||||||
|
margin-top: 15px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: "Roboto";
|
font-family: "Roboto";
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 135%;
|
line-height: 135%;
|
||||||
padding-right: 3px;
|
max-height: 200px; /* Пример ограничения высоты */
|
||||||
}
|
overflow-y: auto;
|
||||||
|
touch-action: none;
|
||||||
.left-widget-text .react-markdown-container {
|
overscroll-behavior: contain;
|
||||||
font-size: 16px;
|
|
||||||
line-height: 135%;
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text .react-markdown-container p {
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 135%;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text .react-markdown-container p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text .react-markdown-container h1,
|
|
||||||
.left-widget-text .react-markdown-container h2,
|
|
||||||
.left-widget-text .react-markdown-container h3,
|
|
||||||
.left-widget-text .react-markdown-container h4,
|
|
||||||
.left-widget-text .react-markdown-container h5,
|
|
||||||
.left-widget-text .react-markdown-container h6 {
|
|
||||||
font-size: 18px;
|
|
||||||
margin-top: 10px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text .react-markdown-container ul,
|
|
||||||
.left-widget-text .react-markdown-container ol {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text .react-markdown-container li {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text .react-markdown-container blockquote {
|
|
||||||
margin-top: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding-left: 12px;
|
|
||||||
border-left: 3px solid rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text .react-markdown-container img {
|
|
||||||
max-width: 100%;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-widget-text .react-markdown-container a {
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-widget-image {
|
.left-widget-image {
|
||||||
@@ -154,27 +93,26 @@
|
|||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Анимация для списка пересадок */
|
||||||
|
.side-menu-sight-transfer-list.entering,
|
||||||
|
.side-menu-sight-transfer-list.entered {
|
||||||
|
max-height: 500px; /* Достаточно большое значение, чтобы вместить все пересадки */
|
||||||
|
opacity: 1;
|
||||||
|
transition: max-height 0.3s ease-out, opacity 0.3s ease-out;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-sight-transfer span {
|
|
||||||
display: inline-block;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu-sight-transfer span.marquee-text {
|
|
||||||
animation: side-menu-marquee 14s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu-sight-transfer-list {
|
.side-menu-sight-transfer-list {
|
||||||
height: 0;
|
max-height: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition:
|
transition: max-height 0.3s ease-out, opacity 0.3s ease-out; /* Анимация при открытии/закрытии */
|
||||||
height 0.3s cubic-bezier(0.16, 1, 0.3, 1),
|
|
||||||
opacity 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Активное состояние - когда список открыт */
|
||||||
.side-menu-sight-transfer-list.open {
|
.side-menu-sight-transfer-list.open {
|
||||||
|
max-height: 500px; /* Достаточно большое значение, чтобы вместить все пересадки */
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,3 @@
|
|||||||
@property --fade-top {
|
|
||||||
syntax: "<length>";
|
|
||||||
inherits: false;
|
|
||||||
initial-value: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@property --fade-bottom {
|
|
||||||
syntax: "<length>";
|
|
||||||
inherits: false;
|
|
||||||
initial-value: 45px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-chevron {
|
|
||||||
0% {
|
|
||||||
transform: rotate(var(--r, 0deg)) translateY(0px) scale(1);
|
|
||||||
}
|
|
||||||
40% {
|
|
||||||
transform: rotate(var(--r, 0deg)) translateY(-4px) scale(1.12);
|
|
||||||
}
|
|
||||||
60% {
|
|
||||||
transform: rotate(var(--r, 0deg)) translateY(-5px) scale(1.14);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(var(--r, 0deg)) translateY(0px) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron-svg {
|
|
||||||
font-size: 20px;
|
|
||||||
animation: pulse-chevron 1.2s ease-in-out infinite;
|
|
||||||
animation-play-state: paused;
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron-svg.is-idle {
|
|
||||||
animation-play-state: running;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron-svg.is-open {
|
|
||||||
--r: 180deg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-widget {
|
.right-widget {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 32px;
|
right: 32px;
|
||||||
@@ -48,8 +6,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-of-sights {
|
.list-of-sights {
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
-webkit-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
-webkit-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
||||||
-moz-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
-moz-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
||||||
box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
||||||
@@ -61,11 +17,11 @@
|
|||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
var(--carrier-right, #806c59);
|
#006f3a;
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
max-height: 68px;
|
max-height: 68px;
|
||||||
transition: max-height 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
transition: max-height 0.15s ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +63,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
width: 128px;
|
width: 128px;
|
||||||
|
|
||||||
background-color: var(--carrier-right-dark, #005a2f);
|
background-color: #0e8953;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-of-sights-title {
|
.list-of-sights-title {
|
||||||
@@ -125,7 +81,7 @@
|
|||||||
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1) 0.1s;
|
transition: opacity 0.15s ease-in-out 0.15s;
|
||||||
|
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
touch-action: pan-y;
|
touch-action: pan-y;
|
||||||
@@ -134,29 +90,6 @@
|
|||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-of-sights-content .scrollable {
|
|
||||||
--fade-top: 0px;
|
|
||||||
--fade-bottom: 45px;
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0px,
|
|
||||||
black var(--fade-top),
|
|
||||||
black calc(100% - var(--fade-bottom)),
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
transition:
|
|
||||||
--fade-top 0.5s ease,
|
|
||||||
--fade-bottom 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-of-sights-content:not(.is-at-top) .scrollable {
|
|
||||||
--fade-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-of-sights-content.is-at-bottom .scrollable {
|
|
||||||
--fade-bottom: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-of-sights-grid {
|
.list-of-sights-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
@@ -170,12 +103,6 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-of-sights-content .custom-scrollbar-track {
|
|
||||||
margin-top: 14px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-component {
|
.sight-component {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -248,18 +175,18 @@
|
|||||||
height: 1px;
|
height: 1px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: rgba(255, 255, 255, 0.3);
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
margin-bottom: 0;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame {
|
.sight-frame {
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100px;
|
bottom: 66px;
|
||||||
left: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
margin-bottom: 32px;
|
||||||
|
width: 550px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
@@ -267,14 +194,8 @@
|
|||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
var(--carrier-right, #806c59);
|
#006f3a;
|
||||||
max-height: calc(100vh - 128px);
|
max-height: calc(100vh - 128px);
|
||||||
transform: translate3d(0, 0, 0);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame.three-d-fullscreen {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-image {
|
.sight-frame-image {
|
||||||
@@ -291,11 +212,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
transition: opacity 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-content.is-switching {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-get-back-wrapper {
|
.sight-frame-get-back-wrapper {
|
||||||
@@ -321,37 +237,15 @@
|
|||||||
rgba(255, 255, 255, 0.22) 0%,
|
rgba(255, 255, 255, 0.22) 0%,
|
||||||
rgba(255, 255, 255, 0.04) 100%
|
rgba(255, 255, 255, 0.04) 100%
|
||||||
),
|
),
|
||||||
rgba(var(--carrier-right-rgb, 128, 108, 89), 0.72);
|
rgba(0, 111, 58, 0.72);
|
||||||
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
color: white;
|
color: white;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-in-text {
|
|
||||||
animation: fadeInText 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeInText {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-title:not(.intro-title) {
|
|
||||||
background:
|
|
||||||
linear-gradient(
|
|
||||||
180deg,
|
|
||||||
rgba(255, 255, 255, 0.2) 0%,
|
|
||||||
rgba(255, 255, 255, 0) 100%
|
|
||||||
),
|
|
||||||
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-title p {
|
.sight-frame-title p {
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
@@ -369,40 +263,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-text-wrapper {
|
.sight-frame-text-wrapper {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
max-height: calc(80vh - 354px);
|
max-height: calc(80vh - 354px);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 16px 16px 76px 16px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-text-wrapper.scrollable-container {
|
.sight-frame-text-wrapper.scrollable-container {
|
||||||
padding: 16px 16px 76px 16px;
|
padding: 16px;
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-text-wrapper.is-intro {
|
|
||||||
height: 0;
|
|
||||||
padding-top: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
border: none;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-loading-placeholder {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.sight-frame-text-wrapper[data-scrollbar-no-transition="true"] .custom-scrollbar-track,
|
|
||||||
.sight-frame-text-wrapper[data-scrollbar-no-transition="true"] .custom-scrollbar-thumb {
|
|
||||||
transition: opacity 0.3s ease !important;
|
|
||||||
}
|
}
|
||||||
.sight-frame-text-wrapper .scrollable-viewport {
|
.sight-frame-text-wrapper .scrollable-viewport {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -413,12 +285,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-text {
|
.sight-frame-text {
|
||||||
display: flow-root; /* BFC prevents margin collapsing for correct scrollHeight measurement */
|
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: "Roboto";
|
font-family: "Roboto";
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 150%;
|
line-height: 150%;
|
||||||
@@ -455,57 +326,13 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-menu-wrapper {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 100px;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 60px;
|
|
||||||
z-index: 2;
|
|
||||||
border-radius: 0 0 10px 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-menu-fade {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 120px;
|
|
||||||
z-index: 100001;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-menu-fade.left {
|
|
||||||
left: 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
to right,
|
|
||||||
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
border-radius: 0 0 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-menu-fade.right {
|
|
||||||
right: 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
to left,
|
|
||||||
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
border-radius: 0 0 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-menu {
|
.sight-frame-menu {
|
||||||
z-index: 100000;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 7px 60px;
|
padding: 7px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 60px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-evenly;
|
justify-content: space-around;
|
||||||
border-radius: 0px 0px 10px 10px;
|
border-radius: 0px 0px 10px 10px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.8);
|
border-top: 1px solid rgba(255, 255, 255, 0.8);
|
||||||
background:
|
background:
|
||||||
@@ -514,22 +341,11 @@
|
|||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
),
|
),
|
||||||
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4);
|
rgba(0, 111, 58, 0.4);
|
||||||
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-menu::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sight-frame-menu {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-menu-point {
|
.sight-frame-menu-point {
|
||||||
@@ -540,17 +356,16 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
transition:
|
transition:
|
||||||
background-color 0.1s ease,
|
background-color 0.1s ease,
|
||||||
color 0.1s ease;
|
color 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-menu-point.active {
|
.sight-frame-menu-point.active {
|
||||||
text-shadow: 0 0 0.4px #fff, 0 0 0.4px #fff;
|
border-bottom: 2px solid #fff;
|
||||||
border-bottom-color: #fff;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-text-wrapper::-webkit-scrollbar-track {
|
.sight-frame-text-wrapper::-webkit-scrollbar-track {
|
||||||
@@ -575,20 +390,30 @@
|
|||||||
}
|
}
|
||||||
.sight-frame {
|
.sight-frame {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.9s ease-out;
|
transform: translateY(20px);
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease-out,
|
||||||
|
transform 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame.is-visible {
|
.sight-frame.is-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
animation: fadeInScale 0.9s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
transform: translateY(0);
|
||||||
|
animation: fadeInScale 0.6s ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeInScale {
|
@keyframes fadeInScale {
|
||||||
from {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
transform: translateY(20px) scale(0.95);
|
||||||
}
|
}
|
||||||
to {
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: translateY(-5px) scale(1.02);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +465,6 @@
|
|||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 150%;
|
line-height: 150%;
|
||||||
padding-bottom: 60px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.svg-container {
|
.svg-container {
|
||||||
@@ -695,20 +519,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.alphabet {
|
.alphabet {
|
||||||
width: 40px;
|
width: 100px;
|
||||||
flex-shrink: 0;
|
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
margin-top: 14px;
|
padding-top: 24px;
|
||||||
margin-bottom: 10px;
|
|
||||||
padding-top: 10px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 16px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
height: 676px;
|
height: 650px;
|
||||||
|
padding-bottom: 30px;
|
||||||
touch-action: pan-y;
|
touch-action: pan-y;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
@@ -758,9 +579,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.alphabet-position {
|
.alphabet-position {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.transfer-button-container {
|
.transfer-button-container {
|
||||||
@@ -787,14 +607,14 @@
|
|||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--carrier-main, #006f3a);
|
border: 1px solid #006f3a;
|
||||||
background:
|
background:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
),
|
),
|
||||||
rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4);
|
rgba(0, 111, 58, 0.4);
|
||||||
|
|
||||||
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
@@ -927,7 +747,7 @@
|
|||||||
border-radius: 32px;
|
border-radius: 32px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
background: var(--carrier-main, #006f3a);
|
background: #006f3a;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,10 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow:
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
|
||||||
/* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */
|
/* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */
|
||||||
padding: 1px; /* Чтобы контент не прилипал к рамке */
|
padding: 1px; /* Чтобы контент не прилипал к рамке */
|
||||||
background:
|
background: linear-gradient(
|
||||||
linear-gradient(
|
|
||||||
to bottom right,
|
to bottom right,
|
||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
@@ -52,7 +50,7 @@
|
|||||||
height: 96px;
|
height: 96px;
|
||||||
background-color: #fcd500;
|
background-color: #fcd500;
|
||||||
color: black;
|
color: black;
|
||||||
border-radius: 10px 0 0 10px;
|
border-radius: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -61,18 +59,6 @@
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.route-widget-number--3 {
|
|
||||||
font-size: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-widget-number--4 {
|
|
||||||
font-size: 38px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-widget-number--5 {
|
|
||||||
font-size: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-widget-content {
|
.route-widget-content {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 257px;
|
width: 257px;
|
||||||
|
|||||||
@@ -13,7 +13,18 @@
|
|||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
),
|
),
|
||||||
var(--carrier-left, #006f3a);
|
#006f3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu-label {
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 0 20px;
|
||||||
|
align-self: flex-start;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 150%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appeal-button {
|
.appeal-button {
|
||||||
@@ -24,12 +35,11 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin-top: 120px;
|
margin-top: 120px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
width: 220px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-buttons {
|
.side-menu-buttons {
|
||||||
width: 220px;
|
width: 220px;
|
||||||
|
margin-top: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-button {
|
.side-menu-button {
|
||||||
@@ -41,6 +51,10 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-menu-button--sights {
|
||||||
|
background-color: #fcd500;
|
||||||
|
}
|
||||||
|
|
||||||
.side-menu-button--active {
|
.side-menu-button--active {
|
||||||
background-color: #fcd500;
|
background-color: #fcd500;
|
||||||
color: #000;
|
color: #000;
|
||||||
@@ -55,6 +69,24 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-menu-carrier-block {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carrier-logo {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu-slogan {
|
||||||
|
margin-top: 4px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 16px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 150%;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.side-menu-bottom-photo {
|
.side-menu-bottom-photo {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 32px;
|
margin-top: 32px;
|
||||||
@@ -106,10 +138,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
3.33% {
|
3.33% {
|
||||||
fill: var(--carrier-left, rgb(76, 175, 75));
|
fill: rgb(76, 175, 75);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
fill: var(--carrier-left, rgb(76, 175, 75));
|
fill: rgb(76, 175, 75);
|
||||||
}
|
}
|
||||||
53.33% {
|
53.33% {
|
||||||
fill: #ffffff;
|
fill: #ffffff;
|
||||||
@@ -159,7 +191,7 @@
|
|||||||
top: -2px;
|
top: -2px;
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
background-color: color-mix(in srgb, var(--carrier-left, #006f3a) 80%, black);
|
background-color: #0e8953;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +200,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
top: 250px;
|
top: 250px;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border-radius: 10px 10px 0px 0px;
|
border-radius: 10px 10px 0px 0px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
@@ -177,7 +207,7 @@
|
|||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
var(--carrier-left, #006f3a);
|
#006f3a;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 288px;
|
width: 288px;
|
||||||
transform: translateY(100%);
|
transform: translateY(100%);
|
||||||
@@ -197,23 +227,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-sights-block {
|
.side-menu-sights-block {
|
||||||
flex: 1;
|
height: calc(100% - 20px);
|
||||||
min-height: 0;
|
margin-left: 20px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
width: 100%;
|
width: auto;
|
||||||
|
max-width: calc(100% - 20px);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-sight {
|
.side-menu-sight {
|
||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
|
margin-right: 20px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
border-bottom: 1px solid
|
border-bottom: 1px solid #0e8953;
|
||||||
color-mix(in srgb, var(--carrier-left, #006f3a) 80%, black);
|
|
||||||
font-family: "Roboto";
|
font-family: "Roboto";
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
@@ -223,17 +253,6 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-sight-wrapper {
|
|
||||||
margin-left: -20px;
|
|
||||||
padding-left: 20px;
|
|
||||||
background-color: transparent;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu-sight-selected-wrapper {
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu-sight > span {
|
.side-menu-sight > span {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -255,6 +274,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-menu-crest {
|
||||||
|
width: 170px;
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.side-menu-control-lang-open {
|
.side-menu-control-lang-open {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -43,8 +43,6 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
|
||||||
transition: opacity 0.5s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar-thumb {
|
.custom-scrollbar-thumb {
|
||||||
@@ -57,7 +55,6 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
transition: opacity 0.5s ease, height 0.5s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar-thumb:active {
|
.custom-scrollbar-thumb:active {
|
||||||
@@ -65,12 +62,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-sights-block .scrollable-viewport {
|
.side-menu-sights-block .scrollable-viewport {
|
||||||
height: calc(98%);
|
height: calc(92%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-sights-block .scrollable {
|
.side-menu-sights-block .scrollable {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-left: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-of-sights-content .scrollable-viewport {
|
.list-of-sights-content .scrollable-viewport {
|
||||||
|
|||||||
@@ -134,58 +134,6 @@ export class PositionAnimator {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Передискретизация пути для обеспечения равномерного расстояния между точками
|
|
||||||
* @param path - массив [lat, lon] или [x, y]
|
|
||||||
* @param segmentLength - желаемое расстояние между точками (в единицах координат)
|
|
||||||
* @returns новый массив точек
|
|
||||||
*/
|
|
||||||
export const resamplePath = <T extends number[]>(path: T[], segmentLength: number): T[] => {
|
|
||||||
if (path.length < 2) return path;
|
|
||||||
|
|
||||||
const newPath: T[] = [path[0]];
|
|
||||||
let leftover = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < path.length - 1; i++) {
|
|
||||||
const p1 = path[i];
|
|
||||||
const p2 = path[i + 1];
|
|
||||||
const dx = p2[0] - p1[0];
|
|
||||||
const dy = p2[1] - p1[1];
|
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (dist === 0) continue;
|
|
||||||
|
|
||||||
let currentDist = segmentLength - leftover;
|
|
||||||
while (currentDist <= dist) {
|
|
||||||
const t = currentDist / dist;
|
|
||||||
const point = new Array(p1.length) as T;
|
|
||||||
for (let j = 0; j < p1.length; j++) {
|
|
||||||
point[j] = p1[j] + (p2[j] - p1[j]) * t;
|
|
||||||
}
|
|
||||||
newPath.push(point);
|
|
||||||
currentDist += segmentLength;
|
|
||||||
}
|
|
||||||
leftover = dist - (currentDist - segmentLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем последнюю точку, если она существенно отличается от последней добавленной
|
|
||||||
const lastP = path[path.length - 1];
|
|
||||||
const lastNewP = newPath[newPath.length - 1];
|
|
||||||
let isDifferent = false;
|
|
||||||
for (let j = 0; j < lastP.length; j++) {
|
|
||||||
if (Math.abs(lastP[j] - lastNewP[j]) > 0.0000001) {
|
|
||||||
isDifferent = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDifferent) {
|
|
||||||
newPath.push(lastP);
|
|
||||||
}
|
|
||||||
|
|
||||||
return newPath;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Класс для анимации по полярным координатам
|
* Класс для анимации по полярным координатам
|
||||||
* Основано на логике анимации из HTML файла (a.html)
|
* Основано на логике анимации из HTML файла (a.html)
|
||||||
|
|||||||
@@ -17,11 +17,6 @@ const isItemVisible = (item: (typeof NAVIGATION_ITEMS.primary)[number]): boolean
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пользователь с ролью ТО всегда видит раздел устройств
|
|
||||||
if (item.path === "/devices" && authStore.hasRole("devices_maintenance_rw")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const routePermissions = item.path ? ROUTE_REQUIRED_RESOURCES[item.path] ?? [] : [];
|
const routePermissions = item.path ? ROUTE_REQUIRED_RESOURCES[item.path] ?? [] : [];
|
||||||
const canAccessRoute = routePermissions.every((permission) =>
|
const canAccessRoute = routePermissions.every((permission) =>
|
||||||
authStore.canAccess(permission),
|
authStore.canAccess(permission),
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#root {
|
|
||||||
margin: 0;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { LanguageSwitcher } from "@widgets";
|
import { LanguageSwitcher } from "@widgets";
|
||||||
import { articlesStore, selectedCityStore } from "@shared";
|
import { articlesStore } from "@shared";
|
||||||
|
|
||||||
const ArticleCreatePage: React.FC = () => {
|
const ArticleCreatePage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { articleData } = articlesStore;
|
const { articleData } = articlesStore;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect } from "react";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { LanguageSwitcher } from "@widgets";
|
import { LanguageSwitcher } from "@widgets";
|
||||||
import { articlesStore, languageStore, selectedCityStore } from "@shared";
|
import { articlesStore, languageStore } from "@shared";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
const ArticleEditPage: React.FC = observer(() => {
|
const ArticleEditPage: React.FC = observer(() => {
|
||||||
@@ -11,11 +11,6 @@ const ArticleEditPage: React.FC = observer(() => {
|
|||||||
|
|
||||||
const { articleData, getArticle } = articlesStore;
|
const { articleData, getArticle } = articlesStore;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Устанавливаем русский язык при загрузке страницы
|
// Устанавливаем русский язык при загрузке страницы
|
||||||
languageStore.setLanguage("ru");
|
languageStore.setLanguage("ru");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { authStore, articlesStore, languageStore, SearchInput, selectedCityStore } from "@shared";
|
import { authStore, articlesStore, languageStore, SearchInput } from "@shared";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Trash2, Eye, Minus } from "lucide-react";
|
import { Trash2, Eye, Minus } from "lucide-react";
|
||||||
@@ -31,7 +31,7 @@ export const ArticleListPage = observer(() => {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
fetchArticles();
|
fetchArticles();
|
||||||
}, [language, selectedCityStore.cityVersion]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{
|
{
|
||||||
@@ -55,12 +55,11 @@ export const ArticleListPage = observer(() => {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button title="Просмотр" onClick={() => navigate(`/article/${params.row.id}`)}>
|
<button onClick={() => navigate(`/article/${params.row.id}`)}>
|
||||||
<Eye size={20} className="text-green-500" />
|
<Eye size={20} className="text-green-500" />
|
||||||
</button>
|
</button>
|
||||||
{canWriteArticles && (
|
{canWriteArticles && (
|
||||||
<button
|
<button
|
||||||
title="Удалить"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
setRowId(params.row.id);
|
setRowId(params.row.id);
|
||||||
@@ -76,16 +75,14 @@ export const ArticleListPage = observer(() => {
|
|||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
const cityId = selectedCityStore.selectedCityId;
|
|
||||||
return articleList[language].data
|
return articleList[language].data
|
||||||
.filter((article) => !query || (article.heading ?? "").toLowerCase().includes(query))
|
.filter((article) => !query || (article.heading ?? "").toLowerCase().includes(query))
|
||||||
.filter((article) => !cityId || article.city_id === cityId)
|
|
||||||
.map((article) => ({
|
.map((article) => ({
|
||||||
id: article.id,
|
id: article.id,
|
||||||
heading: article.heading,
|
heading: article.heading,
|
||||||
body: article.body,
|
body: article.body,
|
||||||
}));
|
}));
|
||||||
}, [articleList[language].data, searchQuery, selectedCityStore.selectedCityId]);
|
}, [articleList[language].data, searchQuery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -108,9 +105,7 @@ export const ArticleListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(rows.length > 0 || searchQuery) && (
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
@@ -118,7 +113,6 @@ export const ArticleListPage = observer(() => {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
checkboxSelection={canWriteArticles}
|
checkboxSelection={canWriteArticles}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
disableRowSelectionOnClick
|
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
onPaginationModelChange={setPaginationModel}
|
onPaginationModelChange={setPaginationModel}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Paper,
|
||||||
TextField,
|
TextField,
|
||||||
Select,
|
Select,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
@@ -19,97 +20,29 @@ import {
|
|||||||
languageStore,
|
languageStore,
|
||||||
isMediaIdEmpty,
|
isMediaIdEmpty,
|
||||||
useSelectedCity,
|
useSelectedCity,
|
||||||
selectedCityStore,
|
|
||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
editSightStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { ImageUploadCard, LanguageSwitcher, VideoPreviewCard } from "@widgets";
|
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
type ColorFields = { main_color: string; left_color: string; right_color: string };
|
|
||||||
|
|
||||||
const colorFields = (data: ColorFields) => ({
|
|
||||||
main_color: data.main_color,
|
|
||||||
left_color: data.left_color,
|
|
||||||
right_color: data.right_color,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ColorPickerField = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (val: string) => void;
|
|
||||||
}) => (
|
|
||||||
<div className="flex items-center gap-3 w-full">
|
|
||||||
<div
|
|
||||||
className="w-10 h-10 rounded border border-gray-300 flex-shrink-0 cursor-pointer overflow-hidden relative"
|
|
||||||
style={{ backgroundColor: value || "#ffffff" }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={value || "#ffffff"}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={label}
|
|
||||||
value={value}
|
|
||||||
placeholder="#000000"
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
InputProps={{
|
|
||||||
endAdornment: value ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange("")}
|
|
||||||
className="text-gray-400 hover:text-gray-600 text-xs px-1"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
) : undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const CarrierCreatePage = observer(() => {
|
export const CarrierCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const {
|
const { createCarrierData, setCreateCarrierData } = carrierStore;
|
||||||
createCarrierData,
|
|
||||||
setCreateCarrierData,
|
|
||||||
setCreateCarrierMedia,
|
|
||||||
} = carrierStore;
|
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const canReadCities = authStore.canRead("cities");
|
const canReadCities = authStore.canRead("cities");
|
||||||
const { selectedCityId, selectedCity } = useSelectedCity();
|
const { selectedCityId, selectedCity } = useSelectedCity();
|
||||||
|
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||||
const [mediaId, setMediaId] = useState("");
|
const [mediaId, setMediaId] = useState("");
|
||||||
const [activeMediaField, setActiveMediaField] = useState<
|
|
||||||
"preview_media" | "video_preview"
|
|
||||||
>("preview_media");
|
|
||||||
const [activeMenuType, setActiveMenuType] = useState<
|
const [activeMenuType, setActiveMenuType] = useState<
|
||||||
| "thumbnail"
|
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||||
| "watermark_lu"
|
|
||||||
| "watermark_rd"
|
|
||||||
| "image"
|
|
||||||
| "video_preview"
|
|
||||||
| null
|
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCities = async () => {
|
const fetchCities = async () => {
|
||||||
if (!authStore.me) {
|
if (!authStore.me) {
|
||||||
@@ -148,16 +81,12 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
createCarrierData[language].full_name,
|
createCarrierData[language].full_name,
|
||||||
createCarrierData[language].short_name,
|
createCarrierData[language].short_name,
|
||||||
selectedCityId,
|
selectedCityId,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
selectedMediaId || "",
|
||||||
language,
|
language,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [selectedCityId, createCarrierData.city_id]);
|
||||||
selectedCityId,
|
|
||||||
createCarrierData,
|
|
||||||
createCarrierData.city_id,
|
|
||||||
language,
|
|
||||||
setCreateCarrierData,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -166,7 +95,7 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
|
|
||||||
toast.success("Перевозчик успешно создан");
|
toast.success("Перевозчик успешно создан");
|
||||||
navigate("/carrier");
|
navigate("/carrier");
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast.error("Ошибка при создании перевозчика");
|
toast.error("Ошибка при создании перевозчика");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -179,22 +108,27 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
media_name?: string;
|
media_name?: string;
|
||||||
media_type: number;
|
media_type: number;
|
||||||
}) => {
|
}) => {
|
||||||
setCreateCarrierMedia(activeMediaField, media.id);
|
setSelectedMediaId(media.id);
|
||||||
|
setCreateCarrierData(
|
||||||
|
createCarrierData[language].full_name,
|
||||||
|
createCarrierData[language].short_name,
|
||||||
|
createCarrierData.city_id,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
media.id,
|
||||||
|
language,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const effectivePreviewMediaUrl = isMediaIdEmpty(
|
const selectedMedia =
|
||||||
createCarrierData.preview_media,
|
selectedMediaId && !isMediaIdEmpty(selectedMediaId)
|
||||||
)
|
? mediaStore.media.find((m) => m.id === selectedMediaId)
|
||||||
|
: null;
|
||||||
|
const effectiveLogoUrl = isMediaIdEmpty(selectedMediaId)
|
||||||
? null
|
? null
|
||||||
: createCarrierData.preview_media;
|
: selectedMedia?.id ?? selectedMediaId ?? null;
|
||||||
const effectiveVideoPreviewId = isMediaIdEmpty(
|
|
||||||
createCarrierData.video_preview,
|
|
||||||
)
|
|
||||||
? null
|
|
||||||
: createCarrierData.video_preview;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
@@ -222,6 +156,8 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
createCarrierData[language].full_name,
|
createCarrierData[language].full_name,
|
||||||
createCarrierData[language].short_name,
|
createCarrierData[language].short_name,
|
||||||
e.target.value as number,
|
e.target.value as number,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
selectedMediaId || "",
|
||||||
language,
|
language,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -244,6 +180,8 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
e.target.value,
|
e.target.value,
|
||||||
createCarrierData[language].short_name,
|
createCarrierData[language].short_name,
|
||||||
createCarrierData.city_id,
|
createCarrierData.city_id,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
selectedMediaId || "",
|
||||||
language,
|
language,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -259,119 +197,59 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
createCarrierData[language].full_name,
|
createCarrierData[language].full_name,
|
||||||
e.target.value,
|
e.target.value,
|
||||||
createCarrierData.city_id,
|
createCarrierData.city_id,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
selectedMediaId || "",
|
||||||
language,
|
language,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-6">
|
<TextField
|
||||||
<div className="flex flex-col gap-1">
|
fullWidth
|
||||||
<ColorPickerField
|
label="Слоган"
|
||||||
label="Основной цвет"
|
value={createCarrierData[language].slogan}
|
||||||
value={createCarrierData.main_color}
|
onChange={(e) =>
|
||||||
onChange={(val) =>
|
setCreateCarrierData(
|
||||||
setCreateCarrierData(
|
createCarrierData[language].full_name,
|
||||||
createCarrierData[language].full_name,
|
createCarrierData[language].short_name,
|
||||||
createCarrierData[language].short_name,
|
createCarrierData.city_id,
|
||||||
createCarrierData.city_id,
|
e.target.value,
|
||||||
language,
|
selectedMediaId || "",
|
||||||
{ ...colorFields(createCarrierData), main_color: val }
|
language,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 pl-1">
|
|
||||||
Используется в: виджет обращений, значки на карте, скопление достопримечательностей на карте, информационный виджет
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="Левый цвет"
|
|
||||||
value={createCarrierData.left_color}
|
|
||||||
onChange={(val) =>
|
|
||||||
setCreateCarrierData(
|
|
||||||
createCarrierData[language].full_name,
|
|
||||||
createCarrierData[language].short_name,
|
|
||||||
createCarrierData.city_id,
|
|
||||||
language,
|
|
||||||
{ ...colorFields(createCarrierData), left_color: val }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 pl-1">
|
|
||||||
Используется в: боковое меню, левый виджет достопримечательности
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="Правый цвет"
|
|
||||||
value={createCarrierData.right_color}
|
|
||||||
onChange={(val) =>
|
|
||||||
setCreateCarrierData(
|
|
||||||
createCarrierData[language].full_name,
|
|
||||||
createCarrierData[language].short_name,
|
|
||||||
createCarrierData.city_id,
|
|
||||||
language,
|
|
||||||
{ ...colorFields(createCarrierData), right_color: val }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 pl-1">
|
|
||||||
Используется в: список достопримечательностей, страница достопримечательности
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full flex justify-center items-start gap-10 flex-wrap">
|
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||||
<div className="w-full max-w-[300px]">
|
<ImageUploadCard
|
||||||
<ImageUploadCard
|
title="Логотип перевозчика"
|
||||||
title="Изображение сайд-меню"
|
imageKey="thumbnail"
|
||||||
imageKey="image"
|
imageUrl={effectiveLogoUrl}
|
||||||
imageUrl={effectivePreviewMediaUrl}
|
onImageClick={() => {
|
||||||
onImageClick={() => {
|
setIsPreviewMediaOpen(true);
|
||||||
setIsPreviewMediaOpen(true);
|
setMediaId(effectiveLogoUrl ?? "");
|
||||||
setMediaId(effectivePreviewMediaUrl ?? "");
|
}}
|
||||||
}}
|
onDeleteImageClick={() => {
|
||||||
onDeleteImageClick={() =>
|
setSelectedMediaId(null);
|
||||||
setCreateCarrierMedia("preview_media", "")
|
setActiveMenuType(null);
|
||||||
}
|
setCreateCarrierData(
|
||||||
onSelectFileClick={() => {
|
createCarrierData[language].full_name,
|
||||||
setActiveMediaField("preview_media");
|
createCarrierData[language].short_name,
|
||||||
setActiveMenuType("image");
|
createCarrierData.city_id,
|
||||||
setIsSelectMediaOpen(true);
|
createCarrierData[language].slogan,
|
||||||
}}
|
"",
|
||||||
setUploadMediaOpen={() => {
|
language,
|
||||||
setActiveMediaField("preview_media");
|
);
|
||||||
setActiveMenuType("image");
|
}}
|
||||||
setIsUploadMediaOpen(true);
|
onSelectFileClick={() => {
|
||||||
}}
|
setActiveMenuType("image");
|
||||||
/>
|
setIsSelectMediaOpen(true);
|
||||||
</div>
|
}}
|
||||||
<div className="w-full max-w-[300px]">
|
setUploadMediaOpen={() => {
|
||||||
<VideoPreviewCard
|
setIsUploadMediaOpen(true);
|
||||||
title="Видео загрузки"
|
setActiveMenuType("image");
|
||||||
videoId={effectiveVideoPreviewId}
|
}}
|
||||||
onVideoClick={() => {
|
/>
|
||||||
if (effectiveVideoPreviewId) {
|
|
||||||
setMediaId(effectiveVideoPreviewId);
|
|
||||||
setIsPreviewMediaOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDeleteVideoClick={() =>
|
|
||||||
setCreateCarrierMedia("video_preview", "")
|
|
||||||
}
|
|
||||||
onSelectVideoClick={(file) => {
|
|
||||||
setActiveMediaField("video_preview");
|
|
||||||
setActiveMenuType("video_preview");
|
|
||||||
if (file) {
|
|
||||||
editSightStore.setFileToUpload(file);
|
|
||||||
setIsUploadMediaOpen(true);
|
|
||||||
} else {
|
|
||||||
setIsSelectMediaOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -398,7 +276,7 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
open={isSelectMediaOpen}
|
open={isSelectMediaOpen}
|
||||||
onClose={() => setIsSelectMediaOpen(false)}
|
onClose={() => setIsSelectMediaOpen(false)}
|
||||||
onSelectMedia={handleMediaSelect}
|
onSelectMedia={handleMediaSelect}
|
||||||
mediaType={activeMediaField === "video_preview" ? 2 : 1}
|
mediaType={1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UploadMediaDialog
|
<UploadMediaDialog
|
||||||
@@ -415,6 +293,6 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
onClose={() => setIsPreviewMediaOpen(false)}
|
onClose={() => setIsPreviewMediaOpen(false)}
|
||||||
mediaId={mediaId}
|
mediaId={mediaId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Paper>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Paper,
|
||||||
TextField,
|
TextField,
|
||||||
Select,
|
Select,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
@@ -20,84 +21,19 @@ import {
|
|||||||
languageStore,
|
languageStore,
|
||||||
isMediaIdEmpty,
|
isMediaIdEmpty,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
selectedCityStore,
|
|
||||||
editSightStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
|
||||||
ImageUploadCard,
|
|
||||||
LanguageSwitcher,
|
|
||||||
VideoPreviewCard,
|
|
||||||
} from "@widgets";
|
|
||||||
import {
|
import {
|
||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
|
|
||||||
type ColorFields = {
|
|
||||||
main_color: string;
|
|
||||||
left_color: string;
|
|
||||||
right_color: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const colorFields = (data: ColorFields) => ({
|
|
||||||
main_color: data.main_color,
|
|
||||||
left_color: data.left_color,
|
|
||||||
right_color: data.right_color,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ColorPickerField = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (val: string) => void;
|
|
||||||
}) => (
|
|
||||||
<div className="flex items-center gap-3 w-full">
|
|
||||||
<div
|
|
||||||
className="w-10 h-10 rounded border border-gray-300 flex-shrink-0 cursor-pointer overflow-hidden relative"
|
|
||||||
style={{ backgroundColor: value || "#ffffff" }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={value || "#ffffff"}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={label}
|
|
||||||
value={value}
|
|
||||||
placeholder="#000000"
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
InputProps={{
|
|
||||||
endAdornment: value ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange("")}
|
|
||||||
className="text-gray-400 hover:text-gray-600 text-xs px-1"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
) : undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const CarrierEditPage = observer(() => {
|
export const CarrierEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const {
|
const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore;
|
||||||
getCarrier,
|
|
||||||
setEditCarrierData,
|
|
||||||
setEditCarrierMedia,
|
|
||||||
editCarrierData,
|
|
||||||
} = carrierStore;
|
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const canReadCities = authStore.canRead("cities");
|
const canReadCities = authStore.canRead("cities");
|
||||||
|
|
||||||
@@ -107,24 +43,12 @@ export const CarrierEditPage = observer(() => {
|
|||||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||||
const [mediaId, setMediaId] = useState("");
|
const [mediaId, setMediaId] = useState("");
|
||||||
|
const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false);
|
||||||
const [initialCityName, setInitialCityName] = useState("");
|
const [initialCityName, setInitialCityName] = useState("");
|
||||||
const [activeMediaField, setActiveMediaField] = useState<
|
|
||||||
"preview_media" | "video_preview"
|
|
||||||
>("preview_media");
|
|
||||||
const [activeMenuType, setActiveMenuType] = useState<
|
const [activeMenuType, setActiveMenuType] = useState<
|
||||||
| "thumbnail"
|
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||||
| "watermark_lu"
|
|
||||||
| "watermark_rd"
|
|
||||||
| "image"
|
|
||||||
| "video_preview"
|
|
||||||
| null
|
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -144,37 +68,29 @@ export const CarrierEditPage = observer(() => {
|
|||||||
const carrierData = await getCarrier(Number(id));
|
const carrierData = await getCarrier(Number(id));
|
||||||
|
|
||||||
if (carrierData) {
|
if (carrierData) {
|
||||||
const colors = {
|
|
||||||
main_color: carrierData.ru?.main_color || "",
|
|
||||||
left_color: carrierData.ru?.left_color || "",
|
|
||||||
right_color: carrierData.ru?.right_color || "",
|
|
||||||
};
|
|
||||||
setEditCarrierData(
|
setEditCarrierData(
|
||||||
carrierData.ru?.full_name || "",
|
carrierData.ru?.full_name || "",
|
||||||
carrierData.ru?.short_name || "",
|
carrierData.ru?.short_name || "",
|
||||||
carrierData.ru?.city_id || 0,
|
carrierData.ru?.city_id || 0,
|
||||||
"ru",
|
carrierData.ru?.slogan || "",
|
||||||
colors,
|
carrierData.ru?.logo || "",
|
||||||
|
"ru"
|
||||||
);
|
);
|
||||||
setEditCarrierData(
|
setEditCarrierData(
|
||||||
carrierData.en?.full_name || "",
|
carrierData.en?.full_name || "",
|
||||||
carrierData.en?.short_name || "",
|
carrierData.en?.short_name || "",
|
||||||
carrierData.en?.city_id || 0,
|
carrierData.en?.city_id || 0,
|
||||||
"en",
|
carrierData.en?.slogan || "",
|
||||||
|
carrierData.en?.logo || "",
|
||||||
|
"en"
|
||||||
);
|
);
|
||||||
setEditCarrierData(
|
setEditCarrierData(
|
||||||
carrierData.zh?.full_name || "",
|
carrierData.zh?.full_name || "",
|
||||||
carrierData.zh?.short_name || "",
|
carrierData.zh?.short_name || "",
|
||||||
carrierData.zh?.city_id || 0,
|
carrierData.zh?.city_id || 0,
|
||||||
"zh",
|
carrierData.zh?.slogan || "",
|
||||||
);
|
carrierData.zh?.logo || "",
|
||||||
setEditCarrierMedia(
|
"zh"
|
||||||
"video_preview",
|
|
||||||
carrierData.ru?.video_preview || "",
|
|
||||||
);
|
|
||||||
setEditCarrierMedia(
|
|
||||||
"preview_media",
|
|
||||||
carrierData.ru?.preview_media || "",
|
|
||||||
);
|
);
|
||||||
setInitialCityName(carrierData.ru?.city || "");
|
setInitialCityName(carrierData.ru?.city || "");
|
||||||
}
|
}
|
||||||
@@ -186,7 +102,7 @@ export const CarrierEditPage = observer(() => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
languageStore.setLanguage("ru");
|
languageStore.setLanguage("ru");
|
||||||
}, [id, getCarrier, setEditCarrierData, setEditCarrierMedia]);
|
}, [id]);
|
||||||
|
|
||||||
const handleEdit = async () => {
|
const handleEdit = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -194,7 +110,7 @@ export const CarrierEditPage = observer(() => {
|
|||||||
await carrierStore.editCarrier(Number(id));
|
await carrierStore.editCarrier(Number(id));
|
||||||
toast.success("Перевозчик успешно обновлен");
|
toast.success("Перевозчик успешно обновлен");
|
||||||
navigate("/carrier");
|
navigate("/carrier");
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast.error("Ошибка при обновлении перевозчика");
|
toast.error("Ошибка при обновлении перевозчика");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -207,19 +123,23 @@ export const CarrierEditPage = observer(() => {
|
|||||||
media_name?: string;
|
media_name?: string;
|
||||||
media_type: number;
|
media_type: number;
|
||||||
}) => {
|
}) => {
|
||||||
setEditCarrierMedia(activeMediaField, media.id);
|
setEditCarrierData(
|
||||||
|
editCarrierData[language].full_name,
|
||||||
|
editCarrierData[language].short_name,
|
||||||
|
editCarrierData.city_id,
|
||||||
|
editCarrierData[language].slogan,
|
||||||
|
media.id,
|
||||||
|
language
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const effectivePreviewMediaUrl = isMediaIdEmpty(
|
const selectedMedia =
|
||||||
editCarrierData.preview_media,
|
editCarrierData.logo && !isMediaIdEmpty(editCarrierData.logo)
|
||||||
)
|
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
|
||||||
|
: null;
|
||||||
|
const effectiveLogoUrl = isMediaIdEmpty(editCarrierData.logo)
|
||||||
? null
|
? null
|
||||||
: editCarrierData.preview_media;
|
: (selectedMedia?.id ?? editCarrierData.logo);
|
||||||
const effectiveVideoPreviewId = isMediaIdEmpty(
|
|
||||||
editCarrierData.video_preview,
|
|
||||||
)
|
|
||||||
? null
|
|
||||||
: editCarrierData.video_preview;
|
|
||||||
|
|
||||||
const baseCities = canReadCities
|
const baseCities = canReadCities
|
||||||
? cityStore.cities["ru"].data
|
? cityStore.cities["ru"].data
|
||||||
@@ -262,7 +182,7 @@ export const CarrierEditPage = observer(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
@@ -289,7 +209,9 @@ export const CarrierEditPage = observer(() => {
|
|||||||
editCarrierData[language].full_name,
|
editCarrierData[language].full_name,
|
||||||
editCarrierData[language].short_name,
|
editCarrierData[language].short_name,
|
||||||
Number(e.target.value),
|
Number(e.target.value),
|
||||||
language,
|
editCarrierData[language].slogan,
|
||||||
|
editCarrierData.logo,
|
||||||
|
language
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -311,7 +233,9 @@ export const CarrierEditPage = observer(() => {
|
|||||||
e.target.value,
|
e.target.value,
|
||||||
editCarrierData[language].short_name,
|
editCarrierData[language].short_name,
|
||||||
editCarrierData.city_id,
|
editCarrierData.city_id,
|
||||||
language,
|
editCarrierData[language].slogan,
|
||||||
|
editCarrierData.logo,
|
||||||
|
language
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -326,122 +250,50 @@ export const CarrierEditPage = observer(() => {
|
|||||||
editCarrierData[language].full_name,
|
editCarrierData[language].full_name,
|
||||||
e.target.value,
|
e.target.value,
|
||||||
editCarrierData.city_id,
|
editCarrierData.city_id,
|
||||||
language,
|
editCarrierData[language].slogan,
|
||||||
|
editCarrierData.logo,
|
||||||
|
language
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-6">
|
<TextField
|
||||||
<div className="flex flex-col gap-1">
|
fullWidth
|
||||||
<ColorPickerField
|
label="Слоган"
|
||||||
label="Основной цвет"
|
value={editCarrierData[language].slogan}
|
||||||
value={editCarrierData.main_color}
|
onChange={(e) =>
|
||||||
onChange={(val) =>
|
setEditCarrierData(
|
||||||
setEditCarrierData(
|
editCarrierData[language].full_name,
|
||||||
editCarrierData[language].full_name,
|
editCarrierData[language].short_name,
|
||||||
editCarrierData[language].short_name,
|
editCarrierData.city_id,
|
||||||
editCarrierData.city_id,
|
e.target.value,
|
||||||
language,
|
editCarrierData.logo,
|
||||||
{ ...colorFields(editCarrierData), main_color: val },
|
language
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 pl-1">
|
|
||||||
Используется в: значки на карте, скопление достопримечательностей
|
|
||||||
на карте, информационный виджет
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="Левый цвет"
|
|
||||||
value={editCarrierData.left_color}
|
|
||||||
onChange={(val) =>
|
|
||||||
setEditCarrierData(
|
|
||||||
editCarrierData[language].full_name,
|
|
||||||
editCarrierData[language].short_name,
|
|
||||||
editCarrierData.city_id,
|
|
||||||
language,
|
|
||||||
{ ...colorFields(editCarrierData), left_color: val },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 pl-1">
|
|
||||||
Используется в: виджет обращений, боковое меню (фон, список
|
|
||||||
остановок), левый виджет достопримечательности
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<ColorPickerField
|
|
||||||
label="Правый цвет"
|
|
||||||
value={editCarrierData.right_color}
|
|
||||||
onChange={(val) =>
|
|
||||||
setEditCarrierData(
|
|
||||||
editCarrierData[language].full_name,
|
|
||||||
editCarrierData[language].short_name,
|
|
||||||
editCarrierData.city_id,
|
|
||||||
language,
|
|
||||||
{ ...colorFields(editCarrierData), right_color: val },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 pl-1">
|
|
||||||
Используется в: список достопримечательностей (фон, карточки),
|
|
||||||
правый виджет достопримечательности
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full flex justify-center items-start gap-10 flex-wrap">
|
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||||
<div className="w-full max-w-[300px]">
|
<ImageUploadCard
|
||||||
<ImageUploadCard
|
title="Логотип перевозчика"
|
||||||
title="Изображение сайд-меню"
|
imageKey="thumbnail"
|
||||||
imageKey="image"
|
imageUrl={effectiveLogoUrl}
|
||||||
imageUrl={effectivePreviewMediaUrl}
|
onImageClick={() => {
|
||||||
onImageClick={() => {
|
setIsPreviewMediaOpen(true);
|
||||||
setIsPreviewMediaOpen(true);
|
setMediaId(effectiveLogoUrl ?? "");
|
||||||
setMediaId(effectivePreviewMediaUrl ?? "");
|
}}
|
||||||
}}
|
onDeleteImageClick={() => {
|
||||||
onDeleteImageClick={() =>
|
setIsDeleteLogoModalOpen(true);
|
||||||
setEditCarrierMedia("preview_media", "")
|
}}
|
||||||
}
|
onSelectFileClick={() => {
|
||||||
onSelectFileClick={() => {
|
setActiveMenuType("image");
|
||||||
setActiveMediaField("preview_media");
|
setIsSelectMediaOpen(true);
|
||||||
setActiveMenuType("image");
|
}}
|
||||||
setIsSelectMediaOpen(true);
|
setUploadMediaOpen={() => {
|
||||||
}}
|
setIsUploadMediaOpen(true);
|
||||||
setUploadMediaOpen={() => {
|
setActiveMenuType("image");
|
||||||
setActiveMediaField("preview_media");
|
}}
|
||||||
setActiveMenuType("image");
|
/>
|
||||||
setIsUploadMediaOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full max-w-[300px]">
|
|
||||||
<VideoPreviewCard
|
|
||||||
title="Видео загрузки"
|
|
||||||
videoId={effectiveVideoPreviewId}
|
|
||||||
onVideoClick={() => {
|
|
||||||
if (effectiveVideoPreviewId) {
|
|
||||||
setMediaId(effectiveVideoPreviewId);
|
|
||||||
setIsPreviewMediaOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDeleteVideoClick={() =>
|
|
||||||
setEditCarrierMedia("video_preview", "")
|
|
||||||
}
|
|
||||||
onSelectVideoClick={(file) => {
|
|
||||||
setActiveMediaField("video_preview");
|
|
||||||
setActiveMenuType("video_preview");
|
|
||||||
if (file) {
|
|
||||||
editSightStore.setFileToUpload(file);
|
|
||||||
setIsUploadMediaOpen(true);
|
|
||||||
} else {
|
|
||||||
setIsSelectMediaOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -467,7 +319,7 @@ export const CarrierEditPage = observer(() => {
|
|||||||
open={isSelectMediaOpen}
|
open={isSelectMediaOpen}
|
||||||
onClose={() => setIsSelectMediaOpen(false)}
|
onClose={() => setIsSelectMediaOpen(false)}
|
||||||
onSelectMedia={handleMediaSelect}
|
onSelectMedia={handleMediaSelect}
|
||||||
mediaType={activeMediaField === "video_preview" ? 2 : 1}
|
mediaType={1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UploadMediaDialog
|
<UploadMediaDialog
|
||||||
@@ -485,6 +337,22 @@ export const CarrierEditPage = observer(() => {
|
|||||||
mediaId={mediaId}
|
mediaId={mediaId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
<DeleteModal
|
||||||
|
open={isDeleteLogoModalOpen}
|
||||||
|
onDelete={() => {
|
||||||
|
setEditCarrierData(
|
||||||
|
editCarrierData[language].full_name,
|
||||||
|
editCarrierData[language].short_name,
|
||||||
|
editCarrierData.city_id,
|
||||||
|
editCarrierData[language].slogan,
|
||||||
|
"",
|
||||||
|
language
|
||||||
|
);
|
||||||
|
setIsDeleteLogoModalOpen(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setIsDeleteLogoModalOpen(false)}
|
||||||
|
edit
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { authStore, carrierStore, cityStore, languageStore, selectedCityStore, SearchInput } from "@shared";
|
import { authStore, carrierStore, cityStore, languageStore, SearchInput } from "@shared";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||||
@@ -39,7 +39,7 @@ export const CarrierListPage = observer(() => {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [language, selectedCityStore.cityVersion]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{
|
{
|
||||||
@@ -98,11 +98,10 @@ export const CarrierListPage = observer(() => {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button title="Редактировать" onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
|
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<Pencil size={20} className="text-blue-500" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Удалить"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
setRowId(params.row.id);
|
setRowId(params.row.id);
|
||||||
@@ -162,21 +161,13 @@ export const CarrierListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(rows.length > 0 || searchQuery) && (
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
checkboxSelection={canWriteCarriers}
|
checkboxSelection={canWriteCarriers}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
disableRowSelectionOnClick
|
|
||||||
onRowDoubleClick={(params) => {
|
|
||||||
if (canWriteCarriers) {
|
|
||||||
navigate(`/carrier/${params.id}/edit`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
onPaginationModelChange={setPaginationModel}
|
onPaginationModelChange={setPaginationModel}
|
||||||
|
|||||||
@@ -17,27 +17,18 @@ import {
|
|||||||
countryStore,
|
countryStore,
|
||||||
languageStore,
|
languageStore,
|
||||||
mediaStore,
|
mediaStore,
|
||||||
snapshotStore,
|
|
||||||
isMediaIdEmpty,
|
isMediaIdEmpty,
|
||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||||
|
|
||||||
export const CityCreatePage = observer(() => {
|
export const CityCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const { createCityData, setCreateCityData, setCreateCityWeatherCode } =
|
const { createCityData, setCreateCityData } = cityStore;
|
||||||
cityStore;
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||||
@@ -61,13 +52,7 @@ export const CityCreatePage = observer(() => {
|
|||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const ruCityName = createCityData.ru.name.trim();
|
|
||||||
await cityStore.createCity();
|
await cityStore.createCity();
|
||||||
try {
|
|
||||||
await snapshotStore.createEmptySnapshot(`${ruCityName}_Пустой_Экспорт`);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Failed to create empty snapshot for city:", e);
|
|
||||||
}
|
|
||||||
toast.success("Город успешно создан");
|
toast.success("Город успешно создан");
|
||||||
navigate("/city");
|
navigate("/city");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -87,7 +72,7 @@ export const CityCreatePage = observer(() => {
|
|||||||
createCityData[language].name,
|
createCityData[language].name,
|
||||||
createCityData.country_code,
|
createCityData.country_code,
|
||||||
media.id,
|
media.id,
|
||||||
language,
|
language
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,7 +111,7 @@ export const CityCreatePage = observer(() => {
|
|||||||
e.target.value,
|
e.target.value,
|
||||||
createCityData.country_code,
|
createCityData.country_code,
|
||||||
createCityData.arms,
|
createCityData.arms,
|
||||||
language,
|
language
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -142,7 +127,7 @@ export const CityCreatePage = observer(() => {
|
|||||||
createCityData[language].name,
|
createCityData[language].name,
|
||||||
e.target.value,
|
e.target.value,
|
||||||
createCityData.arms,
|
createCityData.arms,
|
||||||
language,
|
language
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -154,14 +139,6 @@ export const CityCreatePage = observer(() => {
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="Код города для погоды"
|
|
||||||
value={createCityData.weather_city_code ?? 0}
|
|
||||||
onChange={(e) => setCreateCityWeatherCode(Number(e.target.value))}
|
|
||||||
helperText="ID города брать с ресурса openweathermap.org"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||||
<ImageUploadCard
|
<ImageUploadCard
|
||||||
title="Герб города"
|
title="Герб города"
|
||||||
@@ -176,7 +153,7 @@ export const CityCreatePage = observer(() => {
|
|||||||
createCityData[language].name,
|
createCityData[language].name,
|
||||||
createCityData.country_code,
|
createCityData.country_code,
|
||||||
"",
|
"",
|
||||||
language,
|
language
|
||||||
);
|
);
|
||||||
setActiveMenuType(null);
|
setActiveMenuType(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -23,19 +23,12 @@ import {
|
|||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||||
|
|
||||||
export const CityEditPage = observer(() => {
|
export const CityEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||||
@@ -47,13 +40,7 @@ export const CityEditPage = observer(() => {
|
|||||||
>(null);
|
>(null);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const {
|
const { editCityData, editCity, getCity, setEditCityData } = cityStore;
|
||||||
editCityData,
|
|
||||||
editCity,
|
|
||||||
getCity,
|
|
||||||
setEditCityData,
|
|
||||||
setEditCityWeatherCode,
|
|
||||||
} = cityStore;
|
|
||||||
const { getCountries } = countryStore;
|
const { getCountries } = countryStore;
|
||||||
const { getMedia, getOneMedia } = mediaStore;
|
const { getMedia, getOneMedia } = mediaStore;
|
||||||
|
|
||||||
@@ -87,7 +74,6 @@ export const CityEditPage = observer(() => {
|
|||||||
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
|
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
|
||||||
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
|
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
|
||||||
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
|
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
|
||||||
setEditCityWeatherCode(ruData.weather_city_code ?? 0);
|
|
||||||
|
|
||||||
await getOneMedia(ruData.arms as string);
|
await getOneMedia(ruData.arms as string);
|
||||||
|
|
||||||
@@ -121,7 +107,7 @@ export const CityEditPage = observer(() => {
|
|||||||
: null;
|
: null;
|
||||||
const effectiveArmsUrl = isMediaIdEmpty(editCityData.arms)
|
const effectiveArmsUrl = isMediaIdEmpty(editCityData.arms)
|
||||||
? null
|
? null
|
||||||
: (selectedMedia?.id ?? editCityData.arms);
|
: selectedMedia?.id ?? editCityData.arms;
|
||||||
|
|
||||||
if (isLoadingData) {
|
if (isLoadingData) {
|
||||||
return (
|
return (
|
||||||
@@ -193,14 +179,6 @@ export const CityEditPage = observer(() => {
|
|||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label="Код города для погоды"
|
|
||||||
value={editCityData.weather_city_code ?? 0}
|
|
||||||
onChange={(e) => setEditCityWeatherCode(Number(e.target.value))}
|
|
||||||
helperText="ID города брать с ресурса openweathermap.org"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||||
<ImageUploadCard
|
<ImageUploadCard
|
||||||
title="Герб города"
|
title="Герб города"
|
||||||
|
|||||||
@@ -114,11 +114,10 @@ export const CityListPage = observer(() => {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button title="Редактировать" onClick={() => navigate(`/city/${params.row.id}/edit`)}>
|
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<Pencil size={20} className="text-blue-500" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Удалить"
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
@@ -140,10 +139,7 @@ export const CityListPage = observer(() => {
|
|||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Города</h1>
|
<h1 className="text-2xl">Города</h1>
|
||||||
{canWriteCities && (
|
{canWriteCities && (
|
||||||
<CreateButton
|
<CreateButton label="Создать город" path="/city/create" />
|
||||||
label="Создать город"
|
|
||||||
path="/city/create"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -159,21 +155,13 @@ export const CityListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(rows.length > 0 || searchQuery) && (
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={filteredRows}
|
rows={filteredRows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
checkboxSelection={canWriteCities}
|
checkboxSelection={canWriteCities}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
disableRowSelectionOnClick
|
|
||||||
onRowDoubleClick={(params) => {
|
|
||||||
if (canWriteCities) {
|
|
||||||
navigate(`/city/${params.id}/edit`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
onPaginationModelChange={setPaginationModel}
|
onPaginationModelChange={setPaginationModel}
|
||||||
@@ -206,11 +194,7 @@ export const CityListPage = observer(() => {
|
|||||||
slots={{
|
slots={{
|
||||||
noRowsOverlay: () => (
|
noRowsOverlay: () => (
|
||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
{isLoading ? (
|
{isLoading ? <CircularProgress size={20} /> : "Нет городов"}
|
||||||
<CircularProgress size={20} />
|
|
||||||
) : (
|
|
||||||
"Нет городов"
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -15,18 +15,11 @@ import {
|
|||||||
RU_COUNTRIES,
|
RU_COUNTRIES,
|
||||||
EN_COUNTRIES,
|
EN_COUNTRIES,
|
||||||
ZH_COUNTRIES,
|
ZH_COUNTRIES,
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const CountryAddPage = observer(() => {
|
export const CountryAddPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { createCountryData, setCountryData, createCountry } = countryStore;
|
const { createCountryData, setCountryData, createCountry } = countryStore;
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,12 @@ import { ArrowLeft, Save } from "lucide-react";
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { countryStore, languageStore, selectedCityStore } from "@shared";
|
import { countryStore, languageStore } from "@shared";
|
||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { LanguageSwitcher } from "@widgets";
|
import { LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
export const CountryCreatePage = observer(() => {
|
export const CountryCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const { createCountryData, setCountryData, createCountry } = countryStore;
|
const { createCountryData, setCountryData, createCountry } = countryStore;
|
||||||
|
|||||||
@@ -4,18 +4,12 @@ import { ArrowLeft, Save } from "lucide-react";
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { countryStore, languageStore, LoadingSpinner, selectedCityStore } from "@shared";
|
import { countryStore, languageStore, LoadingSpinner } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LanguageSwitcher } from "@widgets";
|
import { LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
export const CountryEditPage = observer(() => {
|
export const CountryEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
|||||||
@@ -92,10 +92,7 @@ export const CountryListPage = observer(() => {
|
|||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Страны</h1>
|
<h1 className="text-2xl">Страны</h1>
|
||||||
{canWriteCountries && (
|
{canWriteCountries && (
|
||||||
<CreateButton
|
<CreateButton label="Добавить страну" path="/country/add" />
|
||||||
label="Добавить страну"
|
|
||||||
path="/country/add"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -111,16 +108,13 @@ export const CountryListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(rows.length > 0 || searchQuery) && (
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
checkboxSelection={canWriteCountries}
|
checkboxSelection={canWriteCountries}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
disableRowSelectionOnClick
|
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
onPaginationModelChange={setPaginationModel}
|
onPaginationModelChange={setPaginationModel}
|
||||||
@@ -153,11 +147,7 @@ export const CountryListPage = observer(() => {
|
|||||||
slots={{
|
slots={{
|
||||||
noRowsOverlay: () => (
|
noRowsOverlay: () => (
|
||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
{isLoading ? (
|
{isLoading ? <CircularProgress size={20} /> : "Нет стран"}
|
||||||
<CircularProgress size={20} />
|
|
||||||
) : (
|
|
||||||
"Нет стран"
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
cityStore,
|
cityStore,
|
||||||
createSightStore,
|
createSightStore,
|
||||||
languageStore,
|
languageStore,
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import {
|
import {
|
||||||
CreateInformationTab,
|
CreateInformationTab,
|
||||||
@@ -15,7 +14,6 @@ import {
|
|||||||
} from "@widgets";
|
} from "@widgets";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { runInAction } from "mobx";
|
|
||||||
|
|
||||||
function a11yProps(index: number) {
|
function a11yProps(index: number) {
|
||||||
return {
|
return {
|
||||||
@@ -30,12 +28,7 @@ export const CreateSightPage = observer(() => {
|
|||||||
const [value, setValue] = useState(0);
|
const [value, setValue] = useState(0);
|
||||||
const { getCities } = cityStore;
|
const { getCities } = cityStore;
|
||||||
const { getArticles } = articlesStore;
|
const { getArticles } = articlesStore;
|
||||||
const needLeave = createSightStore.needLeaveAgree;
|
const { needLeaveAgree } = createSightStore;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
|
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
|
||||||
setValue(newValue);
|
setValue(newValue);
|
||||||
@@ -43,15 +36,9 @@ export const CreateSightPage = observer(() => {
|
|||||||
|
|
||||||
let blocker = useBlocker(
|
let blocker = useBlocker(
|
||||||
({ currentLocation, nextLocation }) =>
|
({ currentLocation, nextLocation }) =>
|
||||||
needLeave && currentLocation.pathname !== nextLocation.pathname,
|
needLeaveAgree && currentLocation.pathname !== nextLocation.pathname
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (blocker.state === "blocked" && !needLeave) {
|
|
||||||
blocker.proceed();
|
|
||||||
}
|
|
||||||
}, [blocker.state, needLeave]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
if (!authStore.me) {
|
if (!authStore.me) {
|
||||||
@@ -63,14 +50,6 @@ export const CreateSightPage = observer(() => {
|
|||||||
await authStore.fetchMeCities().catch(() => undefined);
|
await authStore.fetchMeCities().catch(() => undefined);
|
||||||
}
|
}
|
||||||
await getArticles(languageStore.language);
|
await getArticles(languageStore.language);
|
||||||
|
|
||||||
const { selectedCityId, selectedCity } = selectedCityStore;
|
|
||||||
if (selectedCityId && selectedCity && !createSightStore.sight.city_id) {
|
|
||||||
runInAction(() => {
|
|
||||||
createSightStore.sight.city_id = selectedCityId;
|
|
||||||
createSightStore.sight.city = selectedCity.name;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
cityStore,
|
cityStore,
|
||||||
editSightStore,
|
editSightStore,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useBlocker, useParams } from "react-router-dom";
|
import { useBlocker, useParams } from "react-router-dom";
|
||||||
|
|
||||||
@@ -26,11 +25,6 @@ export const EditSightPage = observer(() => {
|
|||||||
const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore;
|
const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore;
|
||||||
const { getArticles } = articlesStore;
|
const { getArticles } = articlesStore;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { getCities } = cityStore;
|
const { getCities } = cityStore;
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { mediaStore, MEDIA_TYPE_LABELS, selectedCityStore } from "@shared";
|
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
@@ -20,15 +20,10 @@ export const MediaCreatePage = observer(() => {
|
|||||||
const [type, setType] = useState("");
|
const [type, setType] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await mediaStore.createMedia(name, type, selectedCityStore.selectedCityId);
|
await mediaStore.createMedia(name, type);
|
||||||
toast.success("Медиа успешно создано");
|
toast.success("Медиа успешно создано");
|
||||||
navigate("/media");
|
navigate("/media");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
MEDIA_TYPE_LABELS,
|
MEDIA_TYPE_LABELS,
|
||||||
languageStore,
|
languageStore,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { MediaViewer } from "@widgets";
|
import { MediaViewer } from "@widgets";
|
||||||
|
|
||||||
@@ -43,11 +42,6 @@ export const MediaEditPage = observer(() => {
|
|||||||
const [mediaType, setMediaType] = useState(media?.media_type ?? 1);
|
const [mediaType, setMediaType] = useState(media?.media_type ?? 1);
|
||||||
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>([]);
|
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
mediaStore.getOneMedia(id);
|
mediaStore.getOneMedia(id);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore, SearchInput, selectedCityStore } from "@shared";
|
import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore, SearchInput } from "@shared";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Eye, Trash2, Minus } from "lucide-react";
|
import { Eye, Trash2, Minus } from "lucide-react";
|
||||||
@@ -78,12 +78,11 @@ export const MediaListPage = observer(() => {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button title="Просмотр" onClick={() => navigate(`/media/${params.row.id}`)}>
|
<button onClick={() => navigate(`/media/${params.row.id}`)}>
|
||||||
<Eye size={20} className="text-green-500" />
|
<Eye size={20} className="text-green-500" />
|
||||||
</button>
|
</button>
|
||||||
{canWriteMedia && (
|
{canWriteMedia && (
|
||||||
<button
|
<button
|
||||||
title="Удалить"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
setRowId(params.row.id);
|
setRowId(params.row.id);
|
||||||
@@ -99,23 +98,19 @@ export const MediaListPage = observer(() => {
|
|||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
const cityId = selectedCityStore.selectedCityId;
|
|
||||||
return media
|
return media
|
||||||
.filter((item) => !query || (item.media_name ?? "").toLowerCase().includes(query))
|
.filter((item) => !query || (item.media_name ?? "").toLowerCase().includes(query))
|
||||||
.filter((item) => !cityId || item.city_id === cityId)
|
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
media_name: item.media_name,
|
media_name: item.media_name,
|
||||||
media_type: item.media_type,
|
media_type: item.media_type,
|
||||||
}));
|
}));
|
||||||
}, [media, searchQuery, selectedCityStore.selectedCityId]);
|
}, [media, searchQuery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{(rows.length > 0 || searchQuery) && (
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canWriteMedia && ids.length > 0 && (
|
{canWriteMedia && ids.length > 0 && (
|
||||||
<div className="flex justify-end mb-5 duration-300">
|
<div className="flex justify-end mb-5 duration-300">
|
||||||
@@ -134,7 +129,6 @@ export const MediaListPage = observer(() => {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
checkboxSelection={canWriteMedia}
|
checkboxSelection={canWriteMedia}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
disableRowSelectionOnClick
|
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
onPaginationModelChange={setPaginationModel}
|
onPaginationModelChange={setPaginationModel}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { apiStore } from "../../../client/src/api/ApiStore/store";
|
import { apiStore } from "../../../client/src/api/ApiStore/store";
|
||||||
import App from "../../../client/src/App";
|
import App from "../../../client/src/App";
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { MediaViewer, VideoPreviewCard, ImageUploadCard } from "@widgets";
|
import {
|
||||||
|
MediaViewer,
|
||||||
|
VideoPreviewCard,
|
||||||
|
ImageUploadCard,
|
||||||
|
} from "@widgets";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react";
|
import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
@@ -35,18 +39,11 @@ import type { Route } from "@shared";
|
|||||||
|
|
||||||
export const RouteCreatePage = observer(() => {
|
export const RouteCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [carrier, setCarrier] = useState<string>("");
|
const [carrier, setCarrier] = useState<string>("");
|
||||||
const [routeNumber, setRouteNumber] = useState("");
|
const [routeNumber, setRouteNumber] = useState("");
|
||||||
const [routeCoords, setRouteCoords] = useState("");
|
const [routeCoords, setRouteCoords] = useState("");
|
||||||
const [govRouteNumber, setGovRouteNumber] = useState("");
|
const [govRouteNumber, setGovRouteNumber] = useState("");
|
||||||
const [governorAppeal, setGovernorAppeal] = useState<string>("");
|
const [governorAppeal, setGovernorAppeal] = useState<string>("");
|
||||||
const [buttonText, setButtonText] = useState("");
|
|
||||||
const [direction, setDirection] = useState("backward");
|
const [direction, setDirection] = useState("backward");
|
||||||
const [scaleMin, setScaleMin] = useState("10");
|
const [scaleMin, setScaleMin] = useState("10");
|
||||||
const [scaleMax, setScaleMax] = useState("100");
|
const [scaleMax, setScaleMax] = useState("100");
|
||||||
@@ -54,7 +51,7 @@ export const RouteCreatePage = observer(() => {
|
|||||||
const [turn, setTurn] = useState("");
|
const [turn, setTurn] = useState("");
|
||||||
const [centerLat, setCenterLat] = useState("");
|
const [centerLat, setCenterLat] = useState("");
|
||||||
const [centerLng, setCenterLng] = useState("");
|
const [centerLng, setCenterLng] = useState("");
|
||||||
const [videoTimer, setVideoTimer] = useState(420);
|
const [videoTimer, setVideoTimer] = useState(60);
|
||||||
const [videoPreview, setVideoPreview] = useState<string>("");
|
const [videoPreview, setVideoPreview] = useState<string>("");
|
||||||
const [icon, setIcon] = useState<string>("");
|
const [icon, setIcon] = useState<string>("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -136,16 +133,6 @@ export const RouteCreatePage = observer(() => {
|
|||||||
articlesStore.getArticleList();
|
articlesStore.getArticleList();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleIconSelect = (media: {
|
|
||||||
id: string;
|
|
||||||
filename: string;
|
|
||||||
media_name?: string;
|
|
||||||
media_type: number;
|
|
||||||
}) => {
|
|
||||||
setIcon(media.id);
|
|
||||||
setIsSelectIconDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVideoSelect = (media: {
|
const handleVideoSelect = (media: {
|
||||||
id: string;
|
id: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -176,6 +163,20 @@ export const RouteCreatePage = observer(() => {
|
|||||||
setFileToUpload(null);
|
setFileToUpload(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVideoPreviewClick = () => {
|
||||||
|
setIsVideoPreviewOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIconSelect = (media: {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
media_name?: string;
|
||||||
|
media_type: number;
|
||||||
|
}) => {
|
||||||
|
setIcon(media.id);
|
||||||
|
setIsSelectIconDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const selectedIconMedia =
|
const selectedIconMedia =
|
||||||
icon && !isMediaIdEmpty(icon)
|
icon && !isMediaIdEmpty(icon)
|
||||||
? mediaStore.media.find((m) => m.id === icon)
|
? mediaStore.media.find((m) => m.id === icon)
|
||||||
@@ -285,13 +286,9 @@ export const RouteCreatePage = observer(() => {
|
|||||||
newRoute.governor_appeal = governor_appeal;
|
newRoute.governor_appeal = governor_appeal;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (buttonText.trim()) {
|
await routeStore.createRoute(newRoute);
|
||||||
newRoute.button_text = buttonText.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const newId = await routeStore.createRoute(newRoute);
|
|
||||||
toast.success("Маршрут успешно создан");
|
toast.success("Маршрут успешно создан");
|
||||||
navigate(`/route/${newId}/edit`);
|
navigate(-1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error("Произошла ошибка при создании маршрута");
|
toast.error("Произошла ошибка при создании маршрута");
|
||||||
@@ -404,18 +401,6 @@ export const RouteCreatePage = observer(() => {
|
|||||||
onChange={(e) => setGovRouteNumber(e.target.value)}
|
onChange={(e) => setGovRouteNumber(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
|
||||||
Текст кнопки обращения
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
value={buttonText}
|
|
||||||
onChange={(e) => setButtonText(e.target.value)}
|
|
||||||
placeholder="Обращение губернатора"
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
helperText="Если пусто, будет использован текст по умолчанию"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
Обращение к пассажирам
|
Обращение к пассажирам
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -481,7 +466,7 @@ export const RouteCreatePage = observer(() => {
|
|||||||
<VideoPreviewCard
|
<VideoPreviewCard
|
||||||
title="Видеозаставка"
|
title="Видеозаставка"
|
||||||
videoId={effectiveVideoId}
|
videoId={effectiveVideoId}
|
||||||
onVideoClick={() => setIsVideoPreviewOpen(true)}
|
onVideoClick={handleVideoPreviewClick}
|
||||||
onDeleteVideoClick={() => setVideoPreview("")}
|
onDeleteVideoClick={() => setVideoPreview("")}
|
||||||
onSelectVideoClick={handleVideoFileSelect}
|
onSelectVideoClick={handleVideoFileSelect}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -570,7 +555,7 @@ export const RouteCreatePage = observer(() => {
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label="Таймер показа видео при бездействии (сек)"
|
label="Таймер видео (сек)"
|
||||||
type="number"
|
type="number"
|
||||||
value={videoTimer}
|
value={videoTimer}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -643,10 +628,11 @@ export const RouteCreatePage = observer(() => {
|
|||||||
}}
|
}}
|
||||||
hardcodeType="video_preview"
|
hardcodeType="video_preview"
|
||||||
contextObjectName={routeName || "Маршрут"}
|
contextObjectName={routeName || "Маршрут"}
|
||||||
contextType="route"
|
contextType="sight"
|
||||||
initialFile={fileToUpload || undefined}
|
initialFile={fileToUpload || undefined}
|
||||||
afterUpload={handleVideoUpload}
|
afterUpload={handleVideoUpload}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SelectMediaDialog
|
<SelectMediaDialog
|
||||||
open={isSelectIconDialogOpen}
|
open={isSelectIconDialogOpen}
|
||||||
onClose={() => setIsSelectIconDialogOpen(false)}
|
onClose={() => setIsSelectIconDialogOpen(false)}
|
||||||
|
|||||||
@@ -36,18 +36,11 @@ import {
|
|||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
selectedCityStore,
|
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { LinkedItems } from "../LinekedStations";
|
import { LinkedItems } from "../LinekedStations";
|
||||||
|
|
||||||
export const RouteEditPage = observer(() => {
|
export const RouteEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { editRouteData, copyRouteAction } = routeStore;
|
const { editRouteData, copyRouteAction } = routeStore;
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -244,7 +237,9 @@ export const RouteEditPage = observer(() => {
|
|||||||
media_name?: string;
|
media_name?: string;
|
||||||
media_type: number;
|
media_type: number;
|
||||||
}) => {
|
}) => {
|
||||||
routeStore.setEditRouteData({ video_preview: media.id });
|
routeStore.setEditRouteData({
|
||||||
|
video_preview: media.id,
|
||||||
|
});
|
||||||
setIsSelectVideoDialogOpen(false);
|
setIsSelectVideoDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -263,11 +258,19 @@ export const RouteEditPage = observer(() => {
|
|||||||
media_name?: string;
|
media_name?: string;
|
||||||
media_type: number;
|
media_type: number;
|
||||||
}) => {
|
}) => {
|
||||||
routeStore.setEditRouteData({ video_preview: media.id });
|
routeStore.setEditRouteData({
|
||||||
|
video_preview: media.id,
|
||||||
|
});
|
||||||
setIsUploadVideoDialogOpen(false);
|
setIsUploadVideoDialogOpen(false);
|
||||||
setFileToUpload(null);
|
setFileToUpload(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVideoPreviewClick = () => {
|
||||||
|
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
|
||||||
|
setIsVideoPreviewOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Получаем название выбранной статьи для отображения
|
// Получаем название выбранной статьи для отображения
|
||||||
const selectedArticle = articlesStore.articleList.ru.data.find(
|
const selectedArticle = articlesStore.articleList.ru.data.find(
|
||||||
(article) => article.id === editRouteData.governor_appeal
|
(article) => article.id === editRouteData.governor_appeal
|
||||||
@@ -545,9 +548,9 @@ export const RouteEditPage = observer(() => {
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label="Таймер показа видео при бездействии (сек)"
|
label="Таймер видео (сек)"
|
||||||
type="number"
|
type="number"
|
||||||
value={editRouteData.video_timer ?? 420}
|
value={editRouteData.video_timer ?? 60}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = Math.max(1, Math.round(Number(e.target.value)));
|
const val = Math.max(1, Math.round(Number(e.target.value)));
|
||||||
if (Number.isFinite(val)) {
|
if (Number.isFinite(val)) {
|
||||||
@@ -556,22 +559,6 @@ export const RouteEditPage = observer(() => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
|
||||||
Текст кнопки обращения
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
value={editRouteData.button_text || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
routeStore.setEditRouteData({
|
|
||||||
button_text: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="Обращение губернатора"
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
helperText="Если пусто, будет использован текст по умолчанию"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
Обращение к пассажирам
|
Обращение к пассажирам
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -640,10 +627,10 @@ export const RouteEditPage = observer(() => {
|
|||||||
<VideoPreviewCard
|
<VideoPreviewCard
|
||||||
title="Видеозаставка"
|
title="Видеозаставка"
|
||||||
videoId={effectiveVideoId}
|
videoId={effectiveVideoId}
|
||||||
onVideoClick={() => setIsVideoPreviewOpen(true)}
|
onVideoClick={handleVideoPreviewClick}
|
||||||
onDeleteVideoClick={() =>
|
onDeleteVideoClick={() => {
|
||||||
routeStore.setEditRouteData({ video_preview: "" })
|
routeStore.setEditRouteData({ video_preview: "" });
|
||||||
}
|
}}
|
||||||
onSelectVideoClick={handleVideoFileSelect}
|
onSelectVideoClick={handleVideoFileSelect}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -731,10 +718,11 @@ export const RouteEditPage = observer(() => {
|
|||||||
}}
|
}}
|
||||||
hardcodeType="video_preview"
|
hardcodeType="video_preview"
|
||||||
contextObjectName={editRouteData.route_name || "Маршрут"}
|
contextObjectName={editRouteData.route_name || "Маршрут"}
|
||||||
contextType="route"
|
contextType="sight"
|
||||||
initialFile={fileToUpload || undefined}
|
initialFile={fileToUpload || undefined}
|
||||||
afterUpload={handleVideoUpload}
|
afterUpload={handleVideoUpload}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SelectMediaDialog
|
<SelectMediaDialog
|
||||||
open={isSelectIconDialogOpen}
|
open={isSelectIconDialogOpen}
|
||||||
onClose={() => setIsSelectIconDialogOpen(false)}
|
onClose={() => setIsSelectIconDialogOpen(false)}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { observer } from "mobx-react-lite";
|
|||||||
import { Map, Pencil, Trash2, Minus, Monitor } from "lucide-react";
|
import { Map, Pencil, Trash2, Minus, Monitor } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
import { Box, CircularProgress, Tooltip } from "@mui/material";
|
import { Box, CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
export const RouteListPage = observer(() => {
|
export const RouteListPage = observer(() => {
|
||||||
const { routes, getRoutes, deleteRoute, sightCounts, stationCounts, countsLoading, loadCounts } = routeStore;
|
const { routes, getRoutes, deleteRoute } = routeStore;
|
||||||
const { carriers, getCarriers } = carrierStore;
|
const { carriers, getCarriers } = carrierStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
@@ -38,12 +38,9 @@ export const RouteListPage = observer(() => {
|
|||||||
await getCarriers("zh");
|
await getCarriers("zh");
|
||||||
await getRoutes();
|
await getRoutes();
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
const routeIds = routeStore.routes.data.map((r) => r.id);
|
|
||||||
loadCounts(routeIds);
|
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [language, selectedCityStore.cityVersion]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{
|
{
|
||||||
@@ -131,42 +128,6 @@ export const RouteListPage = observer(() => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
field: "sightCount",
|
|
||||||
headerName: "Достопримечательности",
|
|
||||||
width: 180,
|
|
||||||
align: "center" as const,
|
|
||||||
headerAlign: "center" as const,
|
|
||||||
sortable: true,
|
|
||||||
renderHeader: (params: any) => (
|
|
||||||
<Tooltip title="Отображает количество привязанных достопримечательностей">
|
|
||||||
<span>{params.colDef.headerName}</span>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
|
||||||
{params.value === null ? <CircularProgress size={14} /> : params.value}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "stationCount",
|
|
||||||
headerName: "Остановки",
|
|
||||||
width: 120,
|
|
||||||
align: "center" as const,
|
|
||||||
headerAlign: "center" as const,
|
|
||||||
sortable: true,
|
|
||||||
renderHeader: (params: any) => (
|
|
||||||
<Tooltip title="Отображает количество привязанных остановок">
|
|
||||||
<span>{params.colDef.headerName}</span>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
|
||||||
{params.value === null ? <CircularProgress size={14} /> : params.value}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
...(canShowActionsColumn ? [{
|
...(canShowActionsColumn ? [{
|
||||||
field: "actions",
|
field: "actions",
|
||||||
headerName: "Действия",
|
headerName: "Действия",
|
||||||
@@ -178,23 +139,22 @@ export const RouteListPage = observer(() => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
{canWriteRoutes && (
|
{canWriteRoutes && (
|
||||||
<button title="Редактировать" onClick={() => navigate(`/route/${params.row.id}/edit`)}>
|
<button onClick={() => navigate(`/route/${params.row.id}/edit`)}>
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<Pencil size={20} className="text-blue-500" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canShowRoutePreview && (
|
{canShowRoutePreview && (
|
||||||
<button title="Предпросмотр на карте" onClick={() => navigate(`/route-preview/${params.row.id}`)}>
|
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
|
||||||
<Map size={20} className="text-purple-500" />
|
<Map size={20} className="text-purple-500" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canShowRoutePreview && (
|
{canShowRoutePreview && (
|
||||||
<button title="Демо" onClick={() => window.open(`/demo/${params.row.id}`, "_blank")}>
|
<button onClick={() => window.open(`/demo/${params.row.id}`, "_blank")}>
|
||||||
<Monitor size={20} className="text-green-500" />
|
<Monitor size={20} className="text-green-500" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canWriteRoutes && (
|
{canWriteRoutes && (
|
||||||
<button
|
<button
|
||||||
title="Удалить"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
setRowId(params.row.id);
|
setRowId(params.row.id);
|
||||||
@@ -211,9 +171,6 @@ export const RouteListPage = observer(() => {
|
|||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const { selectedCityId } = selectedCityStore;
|
const { selectedCityId } = selectedCityStore;
|
||||||
if (!selectedCityId) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
let filtered = routes.data;
|
let filtered = routes.data;
|
||||||
if (selectedCityId) {
|
if (selectedCityId) {
|
||||||
@@ -238,10 +195,8 @@ export const RouteListPage = observer(() => {
|
|||||||
route_sys_number: route.route_sys_number,
|
route_sys_number: route.route_sys_number,
|
||||||
route_direction: route.route_direction ? "Прямой" : "Обратный",
|
route_direction: route.route_direction ? "Прямой" : "Обратный",
|
||||||
route_name: route.route_name,
|
route_name: route.route_name,
|
||||||
sightCount: sightCounts.has(route.id) ? sightCounts.get(route.id) : null,
|
|
||||||
stationCount: stationCounts.has(route.id) ? stationCounts.get(route.id) : null,
|
|
||||||
}));
|
}));
|
||||||
}, [routes.data, carriers["ru"].data, selectedCityStore.selectedCityId, searchQuery, sightCounts.size, stationCounts.size, countsLoading]);
|
}, [routes.data, carriers["ru"].data, selectedCityStore.selectedCityId, searchQuery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -251,10 +206,7 @@ export const RouteListPage = observer(() => {
|
|||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Маршруты</h1>
|
<h1 className="text-2xl">Маршруты</h1>
|
||||||
{canWriteRoutes && (
|
{canWriteRoutes && (
|
||||||
<CreateButton
|
<CreateButton label="Создать маршрут" path="/route/create" />
|
||||||
label="Создать маршрут"
|
|
||||||
path="/route/create"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -270,9 +222,7 @@ export const RouteListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(rows.length > 0 || searchQuery) && (
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -280,7 +230,6 @@ export const RouteListPage = observer(() => {
|
|||||||
onRowDoubleClick={(params) => canWriteRoutes && navigate(`/route/${params.row.id}/edit`)}
|
onRowDoubleClick={(params) => canWriteRoutes && navigate(`/route/${params.row.id}/edit`)}
|
||||||
checkboxSelection={canWriteRoutes}
|
checkboxSelection={canWriteRoutes}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
disableRowSelectionOnClick
|
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
onPaginationModelChange={setPaginationModel}
|
onPaginationModelChange={setPaginationModel}
|
||||||
@@ -313,13 +262,7 @@ export const RouteListPage = observer(() => {
|
|||||||
slots={{
|
slots={{
|
||||||
noRowsOverlay: () => (
|
noRowsOverlay: () => (
|
||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
{isLoading ? (
|
{isLoading ? <CircularProgress size={20} /> : "Нет маршрутов"}
|
||||||
<CircularProgress size={20} />
|
|
||||||
) : !selectedCityStore.selectedCityId ? (
|
|
||||||
"Выберите город"
|
|
||||||
) : (
|
|
||||||
"Нет маршрутов"
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
232
src/pages/Route/route-preview/InfiniteCanvas.tsx
Normal file
232
src/pages/Route/route-preview/InfiniteCanvas.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { FederatedMouseEvent, FederatedWheelEvent } from "pixi.js";
|
||||||
|
import { Component, ReactNode, useEffect, useState, useRef } from "react";
|
||||||
|
import { useTransform } from "./TransformContext";
|
||||||
|
import { useMapData } from "./MapDataContext";
|
||||||
|
import { SCALE_FACTOR } from "./Constants";
|
||||||
|
import { useApplication } from "@pixi/react";
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<
|
||||||
|
{ children: ReactNode },
|
||||||
|
{ hasError: boolean }
|
||||||
|
> {
|
||||||
|
state = { hasError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
|
console.error("Error caught:", error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return this.state.hasError ? <p>Whoopsie Daisy!</p> : this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfiniteCanvas({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children?: ReactNode }>) {
|
||||||
|
const {
|
||||||
|
position,
|
||||||
|
setPosition,
|
||||||
|
scale,
|
||||||
|
setScale,
|
||||||
|
rotation,
|
||||||
|
setRotation,
|
||||||
|
setScreenCenter,
|
||||||
|
screenCenter,
|
||||||
|
} = useTransform();
|
||||||
|
const { routeData, originalRouteData, setSelectedSight } = useMapData();
|
||||||
|
|
||||||
|
const applicationRef = useApplication();
|
||||||
|
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [startRotation, setStartRotation] = useState(0);
|
||||||
|
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||||
|
|
||||||
|
const [isUserInteracting, setIsUserInteracting] = useState(false);
|
||||||
|
|
||||||
|
const lastOriginalRotation = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!applicationRef?.app?.canvas) return;
|
||||||
|
|
||||||
|
const canvas = applicationRef.app.canvas;
|
||||||
|
const canvasRect = canvas.getBoundingClientRect();
|
||||||
|
const canvasLeft = canvasRect.left;
|
||||||
|
const canvasTop = canvasRect.top;
|
||||||
|
const centerX = window.innerWidth / 2 - canvasLeft;
|
||||||
|
const centerY = window.innerHeight / 2 - canvasTop;
|
||||||
|
setScreenCenter({ x: centerX, y: centerY });
|
||||||
|
}, [applicationRef?.app, setScreenCenter]);
|
||||||
|
|
||||||
|
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||||
|
setIsPointerDown(true);
|
||||||
|
setIsDragging(false);
|
||||||
|
setIsUserInteracting(true);
|
||||||
|
setStartPosition({
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
});
|
||||||
|
setStartMousePosition({
|
||||||
|
x: e.globalX,
|
||||||
|
y: e.globalY,
|
||||||
|
});
|
||||||
|
setStartRotation(rotation);
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newRotation = originalRouteData?.rotate ?? 0;
|
||||||
|
|
||||||
|
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
|
||||||
|
setRotation((newRotation * Math.PI) / 180);
|
||||||
|
lastOriginalRotation.current = newRotation;
|
||||||
|
}
|
||||||
|
}, [originalRouteData?.rotate, isUserInteracting, setRotation]);
|
||||||
|
|
||||||
|
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||||
|
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 };
|
||||||
|
const startAngle = Math.atan2(
|
||||||
|
startMousePosition.y - center.y,
|
||||||
|
startMousePosition.x - center.x
|
||||||
|
);
|
||||||
|
const currentAngle = Math.atan2(
|
||||||
|
e.globalY - center.y,
|
||||||
|
e.globalX - center.x
|
||||||
|
);
|
||||||
|
|
||||||
|
const rotationDiff = currentAngle - startAngle;
|
||||||
|
|
||||||
|
setRotation(startRotation + rotationDiff);
|
||||||
|
|
||||||
|
const cosDelta = Math.cos(rotationDiff);
|
||||||
|
const sinDelta = Math.sin(rotationDiff);
|
||||||
|
|
||||||
|
setPosition({
|
||||||
|
x:
|
||||||
|
center.x * (1 - cosDelta) +
|
||||||
|
startPosition.x * cosDelta +
|
||||||
|
(center.y - startPosition.y) * sinDelta,
|
||||||
|
y:
|
||||||
|
center.y * (1 - cosDelta) +
|
||||||
|
startPosition.y * cosDelta +
|
||||||
|
(startPosition.x - center.x) * sinDelta,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setRotation(startRotation);
|
||||||
|
setPosition({
|
||||||
|
x: startPosition.x - startMousePosition.x + e.globalX,
|
||||||
|
y: startPosition.y - startMousePosition.y + e.globalY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||||
|
if (!isDragging) {
|
||||||
|
setSelectedSight(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPointerDown(false);
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsUserInteracting(false);
|
||||||
|
}, 100);
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (e: FederatedWheelEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsUserInteracting(true);
|
||||||
|
|
||||||
|
const mouseX = e.globalX - position.x;
|
||||||
|
const mouseY = e.globalY - position.y;
|
||||||
|
|
||||||
|
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
|
||||||
|
const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR;
|
||||||
|
|
||||||
|
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
|
||||||
|
const actualZoomFactor = newScale / scale;
|
||||||
|
|
||||||
|
if (scale === newScale) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsUserInteracting(false);
|
||||||
|
}, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosition({
|
||||||
|
x: position.x + mouseX * (1 - actualZoomFactor),
|
||||||
|
y: position.y + mouseY * (1 - actualZoomFactor),
|
||||||
|
});
|
||||||
|
|
||||||
|
setScale(newScale);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsUserInteracting(false);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applicationRef?.app.render();
|
||||||
|
}, [position, scale, rotation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
{applicationRef?.app && (
|
||||||
|
<pixiGraphics
|
||||||
|
draw={(g) => {
|
||||||
|
const canvas = applicationRef.app.canvas;
|
||||||
|
g.clear();
|
||||||
|
g.rect(0, 0, canvas?.width ?? 0, canvas?.height ?? 0);
|
||||||
|
g.fill("#111");
|
||||||
|
}}
|
||||||
|
eventMode={"static"}
|
||||||
|
interactive
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onGlobalPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerUpOutside={handlePointerUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<pixiContainer
|
||||||
|
x={position.x}
|
||||||
|
y={position.y}
|
||||||
|
scale={scale}
|
||||||
|
rotation={rotation}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</pixiContainer>
|
||||||
|
{/* Show center of the screen.
|
||||||
|
<pixiGraphics
|
||||||
|
eventMode="none"
|
||||||
|
draw={(g) => {
|
||||||
|
g.clear();
|
||||||
|
const center = screenCenter ?? {x: 0, y: 0};
|
||||||
|
g.circle(center.x, center.y, 1);
|
||||||
|
g.fill("#fff");
|
||||||
|
}}
|
||||||
|
/> */}
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Button } from "@mui/material";
|
import { Box, Stack, Typography, Button } from "@mui/material";
|
||||||
import { useNavigate, useNavigationType } from "react-router";
|
import { useNavigate, useNavigationType } from "react-router";
|
||||||
|
import { MediaViewer } from "@widgets";
|
||||||
import { useMapData } from "./MapDataContext";
|
import { useMapData } from "./MapDataContext";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { authInstance } from "@shared";
|
import { authInstance, isMediaIdEmpty } from "@shared";
|
||||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
import LanguageSelector from "./web-gl/LanguageSelector";
|
import LanguageSelector from "./web-gl/LanguageSelector";
|
||||||
|
|
||||||
@@ -14,22 +15,22 @@ type LeftSidebarProps = {
|
|||||||
|
|
||||||
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigationType = useNavigationType();
|
const navigationType = useNavigationType(); // PUSH, POP, REPLACE
|
||||||
const { routeData } = useMapData();
|
const { routeData } = useMapData();
|
||||||
const [previewMedia, setPreviewMedia] = useState<string | null>(null);
|
const [carrierThumbnail, setCarrierThumbnail] = useState<string | null>(null);
|
||||||
const [isPreviewMediaError, setIsPreviewMediaError] = useState(false);
|
const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchCarrierData() {
|
async function fetchCarrierThumbnail() {
|
||||||
if (routeData?.carrier_id) {
|
if (routeData?.carrier_id) {
|
||||||
const carrier = (
|
const { city_id, logo } = (
|
||||||
await authInstance.get(`/carrier/${routeData.carrier_id}`)
|
await authInstance.get(`/carrier/${routeData.carrier_id}`)
|
||||||
).data;
|
).data;
|
||||||
setPreviewMedia(carrier.preview_media ?? null);
|
const { arms } = (await authInstance.get(`/city/${city_id}`)).data;
|
||||||
setIsPreviewMediaError(false);
|
setCarrierThumbnail(arms);
|
||||||
|
setCarrierLogo(logo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchCarrierData();
|
fetchCarrierThumbnail();
|
||||||
}, [routeData?.carrier_id]);
|
}, [routeData?.carrier_id]);
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
@@ -41,128 +42,131 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: 288,
|
|
||||||
height: "100%",
|
height: "100%",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
|
transition: "padding 0.3s ease",
|
||||||
|
p: open ? 2 : 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "stretch",
|
||||||
|
justifyContent: "flex-start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Кнопка назад — вне основного меню */}
|
<Stack
|
||||||
<div
|
direction="column"
|
||||||
style={{
|
height="100%"
|
||||||
padding: "12px 12px 0",
|
width="100%"
|
||||||
opacity: open ? 1 : 0,
|
spacing={4}
|
||||||
pointerEvents: open ? "auto" : "none",
|
alignItems="stretch"
|
||||||
transition: "opacity 0.25s ease",
|
justifyContent="space-between"
|
||||||
}}
|
sx={{
|
||||||
>
|
|
||||||
<Button
|
|
||||||
onClick={handleBack}
|
|
||||||
variant="contained"
|
|
||||||
sx={{
|
|
||||||
backgroundColor: "#222",
|
|
||||||
color: "#fff",
|
|
||||||
borderRadius: 1.5,
|
|
||||||
px: 2,
|
|
||||||
py: 1,
|
|
||||||
"&:hover": { backgroundColor: "#2d2d2d" },
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
startIcon={<ArrowBackIcon />}
|
|
||||||
>
|
|
||||||
Назад
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Основное меню — повторяет .side-menu */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
boxSizing: "border-box",
|
|
||||||
paddingTop: 46,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
height: "calc(100% - 56px)",
|
|
||||||
position: "relative",
|
|
||||||
opacity: open ? 1 : 0,
|
opacity: open ? 1 : 0,
|
||||||
transition: "opacity 0.25s ease",
|
transition: "opacity 0.25s ease",
|
||||||
pointerEvents: open ? "auto" : "none",
|
pointerEvents: open ? "auto" : "none",
|
||||||
|
display: open ? "flex" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Кнопки — .side-menu-buttons */}
|
<div>
|
||||||
<div
|
<Button
|
||||||
style={{
|
onClick={handleBack}
|
||||||
width: 220,
|
variant="contained"
|
||||||
marginTop: routeData?.governor_appeal || 0 > 0 ? 40 : 260,
|
color="primary"
|
||||||
}}
|
sx={{
|
||||||
>
|
backgroundColor: "#222",
|
||||||
<div
|
color: "#fff",
|
||||||
style={{
|
borderRadius: 1.5,
|
||||||
backgroundColor: "#fff",
|
px: 2,
|
||||||
color: "#000",
|
py: 1,
|
||||||
textAlign: "center",
|
marginBottom: 10,
|
||||||
padding: "8px 16px",
|
"&:hover": {
|
||||||
marginBottom: 16,
|
backgroundColor: "#2d2d2d",
|
||||||
borderRadius: 10,
|
},
|
||||||
}}
|
}}
|
||||||
|
fullWidth
|
||||||
|
startIcon={<ArrowBackIcon />}
|
||||||
>
|
>
|
||||||
Достопримечательности
|
Назад
|
||||||
</div>
|
</Button>
|
||||||
<div
|
|
||||||
style={{
|
<Stack
|
||||||
backgroundColor: "#fff",
|
direction="column"
|
||||||
color: "#000",
|
alignItems="center"
|
||||||
textAlign: "center",
|
justifyContent="center"
|
||||||
padding: "8px 16px",
|
spacing={3}
|
||||||
marginBottom: 16,
|
|
||||||
borderRadius: 10,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Остановки
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: 150,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{carrierThumbnail && !isMediaIdEmpty(carrierThumbnail) && (
|
||||||
|
<MediaViewer
|
||||||
|
media={{
|
||||||
|
id: carrierThumbnail,
|
||||||
|
media_type: 1, // Тип "Фото" для логотипа
|
||||||
|
filename: "route_thumbnail",
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
fullHeight
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Typography sx={{ color: "#fff" }} textAlign="center">
|
||||||
|
При поддержке Правительства
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 mt-10">
|
||||||
|
<button className="bg-[#fcd500] text-black px-4 py-2 rounded-md w-full font-medium my-10">
|
||||||
|
Обращение губернатора
|
||||||
|
</button>
|
||||||
|
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
|
||||||
|
Достопримечательности
|
||||||
|
</button>
|
||||||
|
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
|
||||||
|
Остановки
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Нижняя секция — .side-menu-bottom-section */}
|
<Stack
|
||||||
<div
|
direction="column"
|
||||||
style={{
|
alignItems="center"
|
||||||
position: "absolute",
|
maxHeight={150}
|
||||||
bottom: 0,
|
justifyContent="center"
|
||||||
left: 0,
|
flexGrow={1}
|
||||||
width: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* .side-menu-bottom-photo */}
|
{carrierLogo && !isMediaIdEmpty(carrierLogo) && (
|
||||||
<img
|
<MediaViewer
|
||||||
src={
|
media={{
|
||||||
previewMedia && !isPreviewMediaError
|
id: carrierLogo,
|
||||||
? `${import.meta.env.VITE_KRBL_MEDIA}${previewMedia}/download?token=${localStorage.getItem("token") ?? ""}`
|
media_type: 1, // Тип "Фото" для логотипа
|
||||||
: "/side-menu-photo.png"
|
filename: "route_thumbnail_logo",
|
||||||
}
|
}}
|
||||||
onError={() => setIsPreviewMediaError(true)}
|
fullHeight
|
||||||
alt=""
|
/>
|
||||||
style={{
|
)}
|
||||||
width: "288px",
|
</Stack>
|
||||||
marginTop: 32,
|
|
||||||
display: "block",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<Typography
|
||||||
className="absolute bottom-[20px] z-10"
|
variant="h6"
|
||||||
style={{
|
textAlign="center"
|
||||||
right: open ? -520 : -312,
|
sx={{ color: "#fff", marginTop: "auto" }}
|
||||||
transition: "right 0.3s ease",
|
>
|
||||||
}}
|
#ВсемПоПути
|
||||||
>
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<div className="absolute bottom-[20px] -right-[520px] z-10">
|
||||||
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
|
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ export function RightSidebar() {
|
|||||||
bgcolor="primary.main"
|
bgcolor="primary.main"
|
||||||
border="1px solid #e0e0e0"
|
border="1px solid #e0e0e0"
|
||||||
borderRadius={2}
|
borderRadius={2}
|
||||||
zIndex={2}
|
|
||||||
>
|
>
|
||||||
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
||||||
Настройка маршрута
|
Настройка маршрута
|
||||||
|
|||||||
137
src/pages/Route/route-preview/Sight.tsx
Normal file
137
src/pages/Route/route-preview/Sight.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTransform } from "./TransformContext";
|
||||||
|
import { SightData } from "./types";
|
||||||
|
import { Assets, FederatedMouseEvent, Texture } from "pixi.js";
|
||||||
|
|
||||||
|
import { SIGHT_SIZE, UP_SCALE } from "./Constants";
|
||||||
|
import { coordinatesToLocal, localToCoordinates } from "./utils";
|
||||||
|
import { useMapData } from "./MapDataContext";
|
||||||
|
|
||||||
|
interface SightProps {
|
||||||
|
sight: SightData;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Sight = ({ sight, id }: Readonly<SightProps>) => {
|
||||||
|
const { rotation, scale } = useTransform();
|
||||||
|
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) => {
|
||||||
|
setIsPointerDown(true);
|
||||||
|
setIsDragging(false);
|
||||||
|
setStartPosition({
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
});
|
||||||
|
setStartMousePosition({
|
||||||
|
x: e.globalX,
|
||||||
|
y: e.globalY,
|
||||||
|
});
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||||
|
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);
|
||||||
|
const sin = Math.sin(rotation);
|
||||||
|
const newPosition = {
|
||||||
|
x: startPosition.x + dx * cos + dy * sin,
|
||||||
|
y: startPosition.y - dx * sin + dy * cos,
|
||||||
|
};
|
||||||
|
setPosition(newPosition);
|
||||||
|
const coordinates = localToCoordinates(newPosition.x, newPosition.y);
|
||||||
|
setSightCoordinates(sight.id, coordinates.latitude, coordinates.longitude);
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||||
|
setIsPointerDown(false);
|
||||||
|
|
||||||
|
// Если не было перетаскивания, то это клик
|
||||||
|
if (!isDragging) {
|
||||||
|
setSelectedSight(sight);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDragging(false);
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const [texture, setTexture] = useState(Texture.EMPTY);
|
||||||
|
useEffect(() => {
|
||||||
|
Assets.load("/sight_icon.svg").then(setTexture);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {}, [id, sight.latitude, sight.longitude]);
|
||||||
|
|
||||||
|
if (!sight) {
|
||||||
|
console.error("sight is null");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компенсируем масштаб, но при зуме текст немного увеличивается
|
||||||
|
const clampedScale = Math.min(Math.max(scale, 1), 3);
|
||||||
|
const textScaleFactor = 1 + (clampedScale - 1) * 0.4;
|
||||||
|
const compensatedSize = SIGHT_SIZE / scale;
|
||||||
|
const compensatedFontSize = (24 / scale) * textScaleFactor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiContainer
|
||||||
|
rotation={-rotation}
|
||||||
|
eventMode="static"
|
||||||
|
interactive
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onGlobalPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerUpOutside={handlePointerUp}
|
||||||
|
x={position.x * UP_SCALE - SIGHT_SIZE / 2}
|
||||||
|
y={position.y * UP_SCALE - SIGHT_SIZE / 2}
|
||||||
|
>
|
||||||
|
<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={compensatedSize + 1 / scale}
|
||||||
|
y={0}
|
||||||
|
anchor={0.5}
|
||||||
|
style={{
|
||||||
|
fontSize: compensatedFontSize,
|
||||||
|
fontWeight: "bold",
|
||||||
|
fill: "#ffffff",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</pixiContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
541
src/pages/Route/route-preview/Station.tsx
Normal file
541
src/pages/Route/route-preview/Station.tsx
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
import { FederatedMouseEvent, Graphics } from "pixi.js";
|
||||||
|
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BACKGROUND_COLOR,
|
||||||
|
PATH_COLOR,
|
||||||
|
STATION_RADIUS,
|
||||||
|
STATION_OUTLINE_WIDTH,
|
||||||
|
UP_SCALE,
|
||||||
|
} from "./Constants";
|
||||||
|
import { useTransform } from "./TransformContext";
|
||||||
|
import { StationData } from "./types";
|
||||||
|
import { useMapData } from "./MapDataContext";
|
||||||
|
import { coordinatesToLocal } from "./utils";
|
||||||
|
import { languageStore } from "@shared";
|
||||||
|
|
||||||
|
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 координаты.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает координату 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;
|
||||||
|
/** Callback для отслеживания наведения на текст */
|
||||||
|
onTextHover?: (isHovered: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getAnchorFromOffset = (
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number
|
||||||
|
): { x: number; y: number } => {
|
||||||
|
if (offsetX === 0 && offsetY === 0) {
|
||||||
|
return { x: 0.5, y: 0.5 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = Math.hypot(offsetX, offsetY);
|
||||||
|
const nx = offsetX / length;
|
||||||
|
const ny = offsetY / length;
|
||||||
|
|
||||||
|
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
|
||||||
|
};
|
||||||
|
|
||||||
|
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 }}
|
||||||
|
zIndex={999999999999999999}
|
||||||
|
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,
|
||||||
|
|
||||||
|
labelAlign: labelAlignProp = "center",
|
||||||
|
onLabelAlignChange,
|
||||||
|
onTextHover,
|
||||||
|
}: 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);
|
||||||
|
onTextHover?.(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleControlPointerEnter = () => {
|
||||||
|
if (hideTimer.current) {
|
||||||
|
clearTimeout(hideTimer.current);
|
||||||
|
hideTimer.current = null;
|
||||||
|
}
|
||||||
|
setIsControlHovered(true);
|
||||||
|
setIsHovered(true);
|
||||||
|
onTextHover?.(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleControlPointerLeave = () => {
|
||||||
|
setIsControlHovered(false);
|
||||||
|
|
||||||
|
if (!isHovered) {
|
||||||
|
hideTimer.current = setTimeout(() => {
|
||||||
|
setIsHovered(false);
|
||||||
|
onTextHover?.(false);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerLeave = () => {
|
||||||
|
hideTimer.current = setTimeout(() => {
|
||||||
|
setIsHovered(false);
|
||||||
|
|
||||||
|
if (!isControlHovered) {
|
||||||
|
setIsControlHovered(false);
|
||||||
|
}
|
||||||
|
onTextHover?.(false);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
|
||||||
|
}, [station.offset_x, station.offset_y, station.id]);
|
||||||
|
|
||||||
|
const convertNumericAlign = (align: number): LabelAlign => {
|
||||||
|
switch (align) {
|
||||||
|
case 0:
|
||||||
|
return "left";
|
||||||
|
case 1:
|
||||||
|
return "center";
|
||||||
|
case 2:
|
||||||
|
return "right";
|
||||||
|
default:
|
||||||
|
return "center";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 clampedScale = Math.min(Math.max(scale, 1), 3);
|
||||||
|
const textScaleFactor = 1 + (clampedScale - 1) * 0.4;
|
||||||
|
const compensatedRuFontSize = ((26 * 0.75) / scale) * textScaleFactor;
|
||||||
|
const compensatedNameFontSize = ((16 * 0.75) / scale) * textScaleFactor;
|
||||||
|
|
||||||
|
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_screen = e.global.x - mouseStartPos.current.x;
|
||||||
|
const dy_screen = e.global.y - mouseStartPos.current.y;
|
||||||
|
|
||||||
|
const newPosition = {
|
||||||
|
x: dragStartPos.current.x + dx_screen,
|
||||||
|
y: dragStartPos.current.y + dy_screen,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 dynamicAnchor = useMemo(
|
||||||
|
() => getAnchorFromOffset(position.x, position.y),
|
||||||
|
[position.x, position.y]
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSecondLabelAnchor = (): number => {
|
||||||
|
switch (currentLabelAlign) {
|
||||||
|
case "left":
|
||||||
|
return 0;
|
||||||
|
case "center":
|
||||||
|
return 0.5;
|
||||||
|
case "right":
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiContainer
|
||||||
|
x={coordinates.x * UP_SCALE}
|
||||||
|
y={coordinates.y * UP_SCALE}
|
||||||
|
rotation={-rotation}
|
||||||
|
zIndex={isHovered || isControlHovered ? 1000 : 0}
|
||||||
|
eventMode="static"
|
||||||
|
interactive
|
||||||
|
cursor={isDragging ? "grabbing" : "grab"}
|
||||||
|
onPointerOver={handlePointerEnter}
|
||||||
|
onPointerOut={handlePointerLeave}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerUpOutside={handlePointerUp}
|
||||||
|
onGlobalPointerMove={handlePointerMove}
|
||||||
|
>
|
||||||
|
<pixiContainer
|
||||||
|
position={{
|
||||||
|
x:
|
||||||
|
(position.x + Math.cos(Math.atan2(position.y, position.x))) /
|
||||||
|
scale,
|
||||||
|
y:
|
||||||
|
(position.y + Math.sin(Math.atan2(position.y, position.x))) /
|
||||||
|
scale,
|
||||||
|
}}
|
||||||
|
anchor={dynamicAnchor}
|
||||||
|
zIndex={isHovered || isControlHovered ? 1000 : 0}
|
||||||
|
>
|
||||||
|
{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,
|
||||||
|
|
||||||
|
labelAlign,
|
||||||
|
onLabelAlignChange,
|
||||||
|
}: Readonly<StationProps>) => {
|
||||||
|
const [isTextHovered, setIsTextHovered] = useState(false);
|
||||||
|
|
||||||
|
const draw = useCallback(
|
||||||
|
(g: Graphics) => {
|
||||||
|
g.clear();
|
||||||
|
const coordinates = coordinatesToLocal(
|
||||||
|
station.latitude,
|
||||||
|
station.longitude
|
||||||
|
);
|
||||||
|
|
||||||
|
const radius = STATION_RADIUS;
|
||||||
|
const strokeWidth = STATION_OUTLINE_WIDTH;
|
||||||
|
|
||||||
|
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
|
||||||
|
|
||||||
|
if (isTextHovered) {
|
||||||
|
g.fill({ color: 0x00aaff });
|
||||||
|
g.stroke({ color: 0xffffff, width: strokeWidth + 1 });
|
||||||
|
} else {
|
||||||
|
g.fill({ color: PATH_COLOR });
|
||||||
|
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[station.latitude, station.longitude, isTextHovered]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pixiContainer zIndex={isTextHovered ? 1000 : 0}>
|
||||||
|
<pixiGraphics draw={draw} />
|
||||||
|
<StationLabel
|
||||||
|
station={station}
|
||||||
|
ruLabel={ruLabel}
|
||||||
|
labelAlign={labelAlign}
|
||||||
|
onLabelAlignChange={onLabelAlignChange}
|
||||||
|
onTextHover={setIsTextHovered}
|
||||||
|
/>
|
||||||
|
</pixiContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
34
src/pages/Route/route-preview/TravelPath.tsx
Normal file
34
src/pages/Route/route-preview/TravelPath.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Graphics } from "pixi.js";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { PATH_COLOR, PATH_WIDTH } from "./Constants";
|
||||||
|
import { coordinatesToLocal } from "./utils";
|
||||||
|
|
||||||
|
interface TravelPathProps {
|
||||||
|
points: { x: number; y: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TravelPath({ points }: Readonly<TravelPathProps>) {
|
||||||
|
const draw = useCallback(
|
||||||
|
(g: Graphics) => {
|
||||||
|
g.clear();
|
||||||
|
const coordStart = coordinatesToLocal(points[0].x, points[0].y);
|
||||||
|
g.moveTo(coordStart.x, coordStart.y);
|
||||||
|
for (let i = 1; i <= points.length - 1; i++) {
|
||||||
|
const coordinates = coordinatesToLocal(points[i].x, points[i].y);
|
||||||
|
g.lineTo(coordinates.x, coordinates.y);
|
||||||
|
}
|
||||||
|
g.stroke({
|
||||||
|
color: PATH_COLOR,
|
||||||
|
width: PATH_WIDTH,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[points]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (points.length === 0) {
|
||||||
|
console.error("points is empty");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <pixiGraphics draw={draw} />;
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import { Stack, Typography, Box, IconButton } from "@mui/material";
|
|||||||
import { Close } from "@mui/icons-material";
|
import { Close } from "@mui/icons-material";
|
||||||
import { Landmark } from "lucide-react";
|
import { Landmark } from "lucide-react";
|
||||||
import { useMapData } from "./MapDataContext";
|
import { useMapData } from "./MapDataContext";
|
||||||
import { RouteWidget } from "./webgl-prototype/RouteWidget";
|
|
||||||
|
|
||||||
export function Widgets() {
|
export function Widgets() {
|
||||||
const { selectedSight, setSelectedSight } = useMapData();
|
const { selectedSight, setSelectedSight } = useMapData();
|
||||||
@@ -14,11 +13,22 @@ export function Widgets() {
|
|||||||
position="absolute"
|
position="absolute"
|
||||||
top={32}
|
top={32}
|
||||||
left={32}
|
left={32}
|
||||||
zIndex={2}
|
|
||||||
sx={{ pointerEvents: "none" }}
|
sx={{ pointerEvents: "none" }}
|
||||||
>
|
>
|
||||||
{/* Виджет маршрута */}
|
<Stack
|
||||||
<RouteWidget />
|
bgcolor="primary.main"
|
||||||
|
width={361}
|
||||||
|
height={96}
|
||||||
|
p={2}
|
||||||
|
m={2}
|
||||||
|
borderRadius={2}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
>
|
||||||
|
<Typography variant="h6" sx={{ color: "#fff" }}>
|
||||||
|
Остановка
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
{/* Виджет выбранной достопримечательности (заменяет виджет погоды) */}
|
{/* Виджет выбранной достопримечательности (заменяет виджет погоды) */}
|
||||||
<Stack
|
<Stack
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { useRef, useEffect, useState } from "react";
|
import { useRef, useEffect, useState } from "react";
|
||||||
import { Widgets } from "./Widgets";
|
import { Widgets } from "./Widgets";
|
||||||
|
import { extend } from "@pixi/react";
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Graphics,
|
||||||
|
Sprite,
|
||||||
|
Texture,
|
||||||
|
TilingSprite,
|
||||||
|
Text,
|
||||||
|
} from "pixi.js";
|
||||||
import { Box, Stack } from "@mui/material";
|
import { Box, Stack } from "@mui/material";
|
||||||
import { MapDataProvider, useMapData } from "./MapDataContext";
|
import { MapDataProvider, useMapData } from "./MapDataContext";
|
||||||
import { TransformProvider, useTransform } from "./TransformContext";
|
import { TransformProvider, useTransform } from "./TransformContext";
|
||||||
@@ -13,6 +22,15 @@ import { UP_SCALE } from "./Constants";
|
|||||||
import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype";
|
import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype";
|
||||||
import { CircularProgress } from "@mui/material";
|
import { CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
|
extend({
|
||||||
|
Container,
|
||||||
|
Graphics,
|
||||||
|
Sprite,
|
||||||
|
Texture,
|
||||||
|
TilingSprite,
|
||||||
|
Text,
|
||||||
|
});
|
||||||
|
|
||||||
const Loading = () => {
|
const Loading = () => {
|
||||||
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
|
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
|
||||||
|
|
||||||
@@ -36,8 +54,7 @@ export const RoutePreview = () => {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
zIndex: 20,
|
width: isLeftSidebarOpen ? 300 : 0,
|
||||||
width: isLeftSidebarOpen ? 288 : 0,
|
|
||||||
transition: "width 0.3s ease",
|
transition: "width 0.3s ease",
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
@@ -76,10 +93,9 @@ export const RouteMap = observer(() => {
|
|||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previousBodyOverflow = document.body.style.overflow;
|
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = previousBodyOverflow;
|
document.body.style.overflow = "auto";
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -129,14 +145,14 @@ export const RouteMap = observer(() => {
|
|||||||
) {
|
) {
|
||||||
const coordinates = coordinatesToLocal(
|
const coordinates = coordinatesToLocal(
|
||||||
originalRouteData?.center_latitude,
|
originalRouteData?.center_latitude,
|
||||||
originalRouteData?.center_longitude,
|
originalRouteData?.center_longitude
|
||||||
);
|
);
|
||||||
|
|
||||||
setTransform(
|
setTransform(
|
||||||
coordinates.x,
|
coordinates.x,
|
||||||
coordinates.y,
|
coordinates.y,
|
||||||
originalRouteData?.rotate,
|
originalRouteData?.rotate,
|
||||||
originalRouteData?.scale_min,
|
originalRouteData?.scale_min
|
||||||
);
|
);
|
||||||
setIsSetup(true);
|
setIsSetup(true);
|
||||||
}
|
}
|
||||||
@@ -155,6 +171,25 @@ export const RouteMap = observer(() => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
|
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
|
||||||
|
{/* <Application resizeTo={parentRef} background="#000" preference="webgl">
|
||||||
|
<InfiniteCanvas>
|
||||||
|
<TravelPath points={points} />
|
||||||
|
{stationData[language].map((obj, index) => (
|
||||||
|
<Station
|
||||||
|
station={obj}
|
||||||
|
key={obj.id}
|
||||||
|
ruLabel={
|
||||||
|
language === "ru"
|
||||||
|
? stationData.ru[index].name
|
||||||
|
: stationData.ru[index].name
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{originalSightData?.map((sight: SightData, index: number) => {
|
||||||
|
return <Sight sight={sight} id={index} key={sight.id} />;
|
||||||
|
})}
|
||||||
|
</InfiniteCanvas>
|
||||||
|
</Application> */}
|
||||||
<WebGLRouteMapPrototype />
|
<WebGLRouteMapPrototype />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export interface RouteData {
|
|||||||
icon_size?: number;
|
icon_size?: number;
|
||||||
font_size: number;
|
font_size: number;
|
||||||
governor_appeal: number;
|
governor_appeal: number;
|
||||||
button_text?: string;
|
|
||||||
id: number;
|
id: number;
|
||||||
path: [number, number][];
|
path: [number, number][];
|
||||||
rotate: number;
|
rotate: number;
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
.route-widget-label.marquee {
|
|
||||||
display: inline-block;
|
|
||||||
animation: marquee 14s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-widget-subtitle.marquee {
|
|
||||||
display: inline-block;
|
|
||||||
animation: marquee 14s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes marquee {
|
|
||||||
0% {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-widget {
|
|
||||||
width: 361px;
|
|
||||||
height: 96px;
|
|
||||||
position: fixed;
|
|
||||||
display: inline-flex;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
|
||||||
/* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */
|
|
||||||
padding: 1px; /* Чтобы контент не прилипал к рамке */
|
|
||||||
background:
|
|
||||||
linear-gradient(
|
|
||||||
to bottom right,
|
|
||||||
rgba(255, 255, 255, 0.2) 0%,
|
|
||||||
rgba(255, 255, 255, 0) 100%
|
|
||||||
),
|
|
||||||
rgba(179, 165, 152, 0.4);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
pointer-events: auto;
|
|
||||||
z-index: 10000001;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-widget-number {
|
|
||||||
position: absolute;
|
|
||||||
width: fit-content;
|
|
||||||
left: 0px;
|
|
||||||
top: 0px;
|
|
||||||
min-width: 94px;
|
|
||||||
max-width: 100px;
|
|
||||||
height: 96px;
|
|
||||||
background-color: #fcd500;
|
|
||||||
color: black;
|
|
||||||
border-top-left-radius: 10px;
|
|
||||||
border-bottom-left-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 70px;
|
|
||||||
padding: 14px;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-widget-number--3 {
|
|
||||||
font-size: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-widget-number--4 {
|
|
||||||
font-size: 38px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-widget-number--5 {
|
|
||||||
font-size: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-widget-content {
|
|
||||||
overflow: hidden;
|
|
||||||
width: 257px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin-top: 13px;
|
|
||||||
margin-left: 109px;
|
|
||||||
margin-right: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-widget-label {
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 24px;
|
|
||||||
margin: 1px 0;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 24px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-widget-label--medium {
|
|
||||||
font-size: 22px;
|
|
||||||
line-height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-widget-label--small {
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-widget-label--xsmall {
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-widget-subtitle {
|
|
||||||
white-space: nowrap;
|
|
||||||
color: #cbcbcb;
|
|
||||||
font-size: 12px;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: normal;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import styles from "./RouteWidget.module.css";
|
|
||||||
import { useMapData } from "../MapDataContext";
|
|
||||||
import { languageStore } from "@shared";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
|
|
||||||
const shouldAnimate = (text: string | undefined, maxLength: number) =>
|
|
||||||
(text?.length ?? 0) > maxLength;
|
|
||||||
|
|
||||||
const getNumberSizeClass = (text: string | undefined) => {
|
|
||||||
const length = text?.length ?? 0;
|
|
||||||
if (length <= 2) return "";
|
|
||||||
if (length === 3) return styles["route-widget-number--3"];
|
|
||||||
if (length === 4) return styles["route-widget-number--4"];
|
|
||||||
return styles["route-widget-number--5"];
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLabelSizeClass = (text: string | undefined) => {
|
|
||||||
const length = text?.length ?? 0;
|
|
||||||
if (length <= 40) return "";
|
|
||||||
if (length <= 60) return styles["route-widget-label--medium"];
|
|
||||||
if (length <= 80) return styles["route-widget-label--small"];
|
|
||||||
return styles["route-widget-label--xsmall"];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RouteWidget = observer(() => {
|
|
||||||
const { routeData, stationData } = useMapData();
|
|
||||||
const { language } = languageStore;
|
|
||||||
|
|
||||||
const stations = stationData?.[language] ?? stationData?.["ru"] ?? [];
|
|
||||||
const stationsRu = stationData?.["ru"] ?? [];
|
|
||||||
|
|
||||||
const startStation = stations[0];
|
|
||||||
const endStation = stations[stations.length - 1];
|
|
||||||
|
|
||||||
const startStationRu = stationsRu[0];
|
|
||||||
const endStationRu = stationsRu[stationsRu.length - 1];
|
|
||||||
|
|
||||||
const enSubtitle = `${startStationRu?.name ?? ""} - ${endStationRu?.name ?? ""}`;
|
|
||||||
const zhSubtitle = `${startStation?.name ?? ""} - ${endStation?.name ?? ""}`;
|
|
||||||
const subtitle = language === "zh" ? zhSubtitle : enSubtitle;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles["route-widget"]} style={{ position: "relative" }}>
|
|
||||||
<div className={[styles["route-widget-number"], getNumberSizeClass(routeData?.route_sys_number)].join(" ")}>
|
|
||||||
{routeData?.route_sys_number || ""}
|
|
||||||
</div>
|
|
||||||
<div className={styles["route-widget-content"]}>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
styles["route-widget-label"],
|
|
||||||
shouldAnimate(startStation?.name, 18) ? styles["marquee"] : "",
|
|
||||||
getLabelSizeClass(startStation?.name),
|
|
||||||
].join(" ")}
|
|
||||||
title={startStation?.name}
|
|
||||||
>
|
|
||||||
{startStation?.name}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
styles["route-widget-label"],
|
|
||||||
shouldAnimate(endStation?.name, 18) ? styles["marquee"] : "",
|
|
||||||
getLabelSizeClass(endStation?.name),
|
|
||||||
].join(" ")}
|
|
||||||
title={endStation?.name}
|
|
||||||
>
|
|
||||||
{endStation?.name}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
styles["route-widget-subtitle"],
|
|
||||||
shouldAnimate(subtitle, 50) ? styles["marquee"] : "",
|
|
||||||
].join(" ")}
|
|
||||||
title={subtitle}
|
|
||||||
>
|
|
||||||
{subtitle}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1910,8 +1910,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
|
|
||||||
const stationIconSizePercent =
|
const stationIconSizePercent =
|
||||||
liveStationIconSizes.get(station.id) ??
|
liveStationIconSizes.get(station.id) ??
|
||||||
(typeof station.icon_size === "number" &&
|
(typeof station.icon_size === "number" && Number.isFinite(station.icon_size)
|
||||||
Number.isFinite(station.icon_size)
|
|
||||||
? station.icon_size
|
? station.icon_size
|
||||||
: 100);
|
: 100);
|
||||||
const iconSizePx = Math.max(
|
const iconSizePx = Math.max(
|
||||||
@@ -2278,7 +2277,6 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
zIndex: 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{stationData.ru.map((station, index) => {
|
{stationData.ru.map((station, index) => {
|
||||||
@@ -2327,6 +2325,9 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
const stationScreenY =
|
const stationScreenY =
|
||||||
rotatedY * camera.scale + camera.translation.y;
|
rotatedY * camera.scale + camera.translation.y;
|
||||||
|
|
||||||
|
const labelX = stationScreenX + offsetX;
|
||||||
|
const labelY = stationScreenY + offsetY;
|
||||||
|
|
||||||
const backendAlign = station.align;
|
const backendAlign = station.align;
|
||||||
|
|
||||||
const anchor = getAnchorFromOffset(backendAlign ?? 2);
|
const anchor = getAnchorFromOffset(backendAlign ?? 2);
|
||||||
@@ -2336,6 +2337,8 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
|
|
||||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||||
|
|
||||||
|
const cssX = labelX / dpr;
|
||||||
|
const cssY = labelY / dpr;
|
||||||
const rotationCss = `${rotationAngle}rad`;
|
const rotationCss = `${rotationAngle}rad`;
|
||||||
const counterRotationCss = `${-rotationAngle}rad`;
|
const counterRotationCss = `${-rotationAngle}rad`;
|
||||||
|
|
||||||
@@ -2354,13 +2357,6 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
const scaleFactor = 1 + (zoomClampedScale - 1) * 0.4;
|
const scaleFactor = 1 + (zoomClampedScale - 1) * 0.4;
|
||||||
|
|
||||||
const primaryFontSize = 16 * fontScale * scaleFactor;
|
const primaryFontSize = 16 * fontScale * scaleFactor;
|
||||||
|
|
||||||
const mainLabelHeight = primaryFontSize * 1.2;
|
|
||||||
const labelX = stationScreenX + offsetX;
|
|
||||||
const labelY = stationScreenY + offsetY + mainLabelHeight / 2;
|
|
||||||
|
|
||||||
const cssX = labelX / dpr;
|
|
||||||
const cssY = labelY / dpr;
|
|
||||||
const secondaryFontSize = 13 * fontScale * scaleFactor;
|
const secondaryFontSize = 13 * fontScale * scaleFactor;
|
||||||
|
|
||||||
const secondaryMarginTop = 5 * fontScale * scaleFactor;
|
const secondaryMarginTop = 5 * fontScale * scaleFactor;
|
||||||
@@ -2406,7 +2402,13 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
hoveredStationIconId === station.id ||
|
hoveredStationIconId === station.id ||
|
||||||
resizingStationIconId === station.id;
|
resizingStationIconId === station.id;
|
||||||
|
|
||||||
const secondaryLineHeight = 1.2 * scaleFactor;
|
const secondaryLineHeight = 1.2;
|
||||||
|
const secondaryHeight = showSecondary
|
||||||
|
? secondaryFontSize * secondaryLineHeight
|
||||||
|
: 0;
|
||||||
|
const menuPaddingTop = showSecondary
|
||||||
|
? Math.max(0, secondaryHeight - secondaryMarginTop) + 3
|
||||||
|
: 3;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={station.id}>
|
<div key={station.id}>
|
||||||
@@ -2436,7 +2438,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
color: "#fff",
|
color: "#fff",
|
||||||
fontFamily: "Roboto, sans-serif",
|
fontFamily: "Roboto, sans-serif",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
pointerEvents: "none",
|
pointerEvents: "auto",
|
||||||
cursor: "grab",
|
cursor: "grab",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
@@ -2444,16 +2446,14 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "inline-block",
|
pointerEvents: "auto",
|
||||||
pointerEvents: "none",
|
|
||||||
transformOrigin: "left center",
|
transformOrigin: "left center",
|
||||||
transform: `rotate(${rotationCss})`,
|
transform: `rotate(${rotationCss})`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "inline-block",
|
pointerEvents: "auto",
|
||||||
pointerEvents: "none",
|
|
||||||
transformOrigin: "left center",
|
transformOrigin: "left center",
|
||||||
transform: `rotate(${counterRotationCss})`,
|
transform: `rotate(${counterRotationCss})`,
|
||||||
}}
|
}}
|
||||||
@@ -2547,7 +2547,6 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontSize: primaryFontSize,
|
fontSize: primaryFontSize,
|
||||||
textShadow: "0 0 4px rgba(0,0,0,0.6)",
|
textShadow: "0 0 4px rgba(0,0,0,0.6)",
|
||||||
@@ -2556,26 +2555,26 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{station.name}
|
{station.name}
|
||||||
{showSecondary ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "100%",
|
|
||||||
marginTop: -1 * secondaryMarginTop,
|
|
||||||
fontWeight: 400,
|
|
||||||
fontSize: secondaryFontSize,
|
|
||||||
lineHeight: secondaryLineHeight,
|
|
||||||
color: "#CBCBCB",
|
|
||||||
textShadow: "0 0 3px rgba(0,0,0,0.4)",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
...secondaryPositionStyle,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{secondaryStation?.name}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
{showSecondary ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "100%",
|
||||||
|
marginTop: -1 * secondaryMarginTop,
|
||||||
|
fontWeight: 400,
|
||||||
|
fontSize: secondaryFontSize,
|
||||||
|
lineHeight: secondaryLineHeight,
|
||||||
|
color: "#CBCBCB",
|
||||||
|
textShadow: "0 0 3px rgba(0,0,0,0.4)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
...secondaryPositionStyle,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{secondaryStation?.name}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2586,9 +2585,9 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
top: "100%",
|
top: "100%",
|
||||||
left: "50%",
|
left: "50%",
|
||||||
transform: "translateX(-50%)",
|
transform: "translateX(-50%)",
|
||||||
paddingTop: "8px",
|
paddingTop: menuPaddingTop,
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
zIndex: 1000000,
|
zIndex: 10,
|
||||||
cursor: "default",
|
cursor: "default",
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
@@ -2707,14 +2706,13 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
? camera.scale /
|
? camera.scale /
|
||||||
Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6)
|
Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6)
|
||||||
: 1;
|
: 1;
|
||||||
const sightIconSizePercent =
|
const sightIconSizePercent = sight.is_default_icon === false
|
||||||
sight.is_default_icon === false
|
? (liveSightIconSizes.get(sight.id) ??
|
||||||
? (liveSightIconSizes.get(sight.id) ??
|
(typeof sight.icon_size === "number" &&
|
||||||
(typeof sight.icon_size === "number" &&
|
Number.isFinite(sight.icon_size)
|
||||||
Number.isFinite(sight.icon_size)
|
? sight.icon_size
|
||||||
? sight.icon_size
|
: 100))
|
||||||
: 100))
|
: (routeData?.icon_size ?? originalRouteData?.icon_size ?? 100);
|
||||||
: (routeData?.icon_size ?? originalRouteData?.icon_size ?? 100);
|
|
||||||
const iconSize =
|
const iconSize =
|
||||||
30 *
|
30 *
|
||||||
clamp(sightIconSizePercent / 100, 0.1, 10) *
|
clamp(sightIconSizePercent / 100, 0.1, 10) *
|
||||||
@@ -2725,10 +2723,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
resizingSightIconId === sight.id);
|
resizingSightIconId === sight.id);
|
||||||
const iconLeft = cssX - iconSize;
|
const iconLeft = cssX - iconSize;
|
||||||
const iconTop = cssY - iconSize;
|
const iconTop = cssY - iconSize;
|
||||||
const sightZoomClampedScale = Math.min(
|
const sightZoomClampedScale = Math.min(Math.max(camera.scale, 1), 3);
|
||||||
Math.max(camera.scale, 1),
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
const sightScaleFactor = 1 + (sightZoomClampedScale - 1) * 0.4;
|
const sightScaleFactor = 1 + (sightZoomClampedScale - 1) * 0.4;
|
||||||
const labelHeight = 24 * sightScaleFactor;
|
const labelHeight = 24 * sightScaleFactor;
|
||||||
const labelPadding = 6 * sightScaleFactor;
|
const labelPadding = 6 * sightScaleFactor;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const SightListPage = observer(() => {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
fetchSights();
|
fetchSights();
|
||||||
}, [language, selectedCityStore.cityVersion]);
|
}, [language]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{
|
{
|
||||||
@@ -105,11 +105,10 @@ export const SightListPage = observer(() => {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
<div className="flex h-full gap-7 justify-center items-center">
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
<button title="Редактировать" onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
|
<button onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
|
||||||
<Pencil size={20} className="text-blue-500" />
|
<Pencil size={20} className="text-blue-500" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Удалить"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
setRowId(params.row.id);
|
setRowId(params.row.id);
|
||||||
@@ -122,11 +121,8 @@ export const SightListPage = observer(() => {
|
|||||||
}] : []),
|
}] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const { selectedCityId } = selectedCityStore;
|
|
||||||
const filteredSights = useMemo(() => {
|
const filteredSights = useMemo(() => {
|
||||||
if (!selectedCityId) {
|
const { selectedCityId } = selectedCityStore;
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const allowedCityIds = canReadCities
|
const allowedCityIds = canReadCities
|
||||||
? null
|
? null
|
||||||
: authStore.meCities["ru"].map((c) => c.city_id);
|
: authStore.meCities["ru"].map((c) => c.city_id);
|
||||||
@@ -135,12 +131,12 @@ export const SightListPage = observer(() => {
|
|||||||
if (allowedCityIds && !allowedCityIds.includes(sight.city_id)) {
|
if (allowedCityIds && !allowedCityIds.includes(sight.city_id)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (sight.city_id !== selectedCityId) {
|
if (selectedCityId && sight.city_id !== selectedCityId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [sights, selectedCityId, canReadCities, authStore.meCities]);
|
}, [sights, selectedCityStore.selectedCityId, canReadCities, authStore.meCities]);
|
||||||
|
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
const rows = filteredSights
|
const rows = filteredSights
|
||||||
@@ -181,9 +177,7 @@ export const SightListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(rows.length > 0 || searchQuery) && (
|
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -191,7 +185,6 @@ export const SightListPage = observer(() => {
|
|||||||
onRowDoubleClick={(params) => canWriteSights && navigate(`/sight/${params.row.id}/edit`)}
|
onRowDoubleClick={(params) => canWriteSights && navigate(`/sight/${params.row.id}/edit`)}
|
||||||
checkboxSelection={canWriteSights}
|
checkboxSelection={canWriteSights}
|
||||||
disableRowSelectionExcludeModel
|
disableRowSelectionExcludeModel
|
||||||
disableRowSelectionOnClick
|
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
paginationModel={paginationModel}
|
paginationModel={paginationModel}
|
||||||
onPaginationModelChange={setPaginationModel}
|
onPaginationModelChange={setPaginationModel}
|
||||||
@@ -222,8 +215,6 @@ export const SightListPage = observer(() => {
|
|||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<CircularProgress size={20} />
|
<CircularProgress size={20} />
|
||||||
) : !selectedCityId ? (
|
|
||||||
"Выберите город"
|
|
||||||
) : (
|
) : (
|
||||||
"Нет достопримечательностей"
|
"Нет достопримечательностей"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,155 +1,17 @@
|
|||||||
import {
|
import { Button, TextField } from "@mui/material";
|
||||||
Button,
|
import { snapshotStore } from "@shared";
|
||||||
TextField,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { snapshotStore, authStore, routeStore, selectedCityStore, cityStore, carrierStore } from "@shared";
|
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { runInAction } from "mobx";
|
import { runInAction } from "mobx";
|
||||||
|
|
||||||
function escapeRegex(s: string) {
|
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildExportNameRegex(cityNames: string[]): RegExp {
|
|
||||||
if (!cityNames.length) return /.+/;
|
|
||||||
const pattern = cityNames.map(escapeRegex).join("|");
|
|
||||||
return new RegExp(`^(${pattern})_.+$`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SnapshotCreatePage = observer(() => {
|
export const SnapshotCreatePage = observer(() => {
|
||||||
const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore;
|
const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
selectedCityStore.setIsLocked(true);
|
|
||||||
return () => selectedCityStore.setIsLocked(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [nameError, setNameError] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [duplicateWarningOpen, setDuplicateWarningOpen] = useState(false);
|
|
||||||
const [duplicateRouteNumbers, setDuplicateRouteNumbers] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const exportNameRegex = useMemo(() => {
|
|
||||||
const names = cityStore.cities["ru"].data.map((c) => c.name.trim());
|
|
||||||
return buildExportNameRegex(names);
|
|
||||||
}, [cityStore.cities["ru"].data.length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!cityStore.cities["ru"].loaded) {
|
|
||||||
cityStore.getCities("ru");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const canReadRoutes = authStore.canRead("routes");
|
|
||||||
|
|
||||||
const startExport = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const id = await createSnapshot(name);
|
|
||||||
|
|
||||||
await getSnapshotStatus(id);
|
|
||||||
|
|
||||||
while (snapshotStore.snapshotStatus?.Status != "done") {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
await getSnapshotStatus(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshotStore.snapshotStatus?.Status === "done") {
|
|
||||||
toast.success("Экспорт медиа успешно создан");
|
|
||||||
|
|
||||||
runInAction(() => {
|
|
||||||
snapshotStore.snapshotStatus = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
await getStorageInfo();
|
|
||||||
navigate(-1);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error("Ошибка при создании экспорта медиа");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!canReadRoutes) {
|
|
||||||
await startExport();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
runInAction(() => {
|
|
||||||
routeStore.routes.loaded = false;
|
|
||||||
});
|
|
||||||
await routeStore.getRoutes();
|
|
||||||
await carrierStore.getCarriers("ru");
|
|
||||||
|
|
||||||
const routes = routeStore.routes.data;
|
|
||||||
const carriers = carrierStore.carriers.ru.data;
|
|
||||||
const carrierCityMap = new Map<number, number>();
|
|
||||||
for (const c of carriers) {
|
|
||||||
carrierCityMap.set(c.id, c.city_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const duplicateMessages: string[] = [];
|
|
||||||
|
|
||||||
const directionKey = new Map<string, number>();
|
|
||||||
for (const route of routes) {
|
|
||||||
const num = (route.route_sys_number ?? "").trim();
|
|
||||||
if (!num) continue;
|
|
||||||
const cityId = carrierCityMap.get(route.carrier_id) ?? 0;
|
|
||||||
const key = `${num}|${route.route_direction}|${cityId}`;
|
|
||||||
directionKey.set(key, (directionKey.get(key) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
for (const [key, count] of directionKey) {
|
|
||||||
if (count > 1) {
|
|
||||||
const [num, dir] = key.split("|");
|
|
||||||
const dirLabel = dir === "true" ? "прямой" : "обратный";
|
|
||||||
duplicateMessages.push(
|
|
||||||
`Дублируется маршрут №${num} (${dirLabel})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cityPerNumber = new Map<string, Set<number>>();
|
|
||||||
for (const route of routes) {
|
|
||||||
const num = (route.route_sys_number ?? "").trim();
|
|
||||||
if (!num) continue;
|
|
||||||
const cityId = carrierCityMap.get(route.carrier_id) ?? 0;
|
|
||||||
if (!cityPerNumber.has(num)) {
|
|
||||||
cityPerNumber.set(num, new Set());
|
|
||||||
}
|
|
||||||
cityPerNumber.get(num)!.add(cityId);
|
|
||||||
}
|
|
||||||
for (const [num, cities] of cityPerNumber) {
|
|
||||||
if (cities.size > 1) {
|
|
||||||
duplicateMessages.push(
|
|
||||||
`Маршрут №${num} присутствует в нескольких городах`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duplicateMessages.length > 0) {
|
|
||||||
setDuplicateRouteNumbers(duplicateMessages);
|
|
||||||
setDuplicateWarningOpen(true);
|
|
||||||
} else {
|
|
||||||
await startExport();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await startExport();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-[400px] flex justify-center items-center">
|
<div className="w-full h-[400px] flex justify-center items-center">
|
||||||
@@ -170,19 +32,7 @@ export const SnapshotCreatePage = observer(() => {
|
|||||||
label="Название"
|
label="Название"
|
||||||
required
|
required
|
||||||
value={name}
|
value={name}
|
||||||
error={!!nameError}
|
onChange={(e) => setName(e.target.value)}
|
||||||
helperText={nameError ?? " "}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
setName(val);
|
|
||||||
const trimmed = val.trim();
|
|
||||||
const hasFullFormat = trimmed.includes("_") && trimmed.split("_").slice(1).join("_").length > 0;
|
|
||||||
if (hasFullFormat && !exportNameRegex.test(trimmed)) {
|
|
||||||
setNameError("Название должно начинаться с названия существующего города");
|
|
||||||
} else {
|
|
||||||
setNameError(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -190,8 +40,36 @@ export const SnapshotCreatePage = observer(() => {
|
|||||||
color="primary"
|
color="primary"
|
||||||
className="w-min flex gap-2 items-center"
|
className="w-min flex gap-2 items-center"
|
||||||
startIcon={<Save size={20} />}
|
startIcon={<Save size={20} />}
|
||||||
onClick={handleSave}
|
onClick={async () => {
|
||||||
disabled={isLoading || !exportNameRegex.test(name.trim())}
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const id = await createSnapshot(name);
|
||||||
|
|
||||||
|
await getSnapshotStatus(id);
|
||||||
|
|
||||||
|
while (snapshotStore.snapshotStatus?.Status != "done") {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
await getSnapshotStatus(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshotStore.snapshotStatus?.Status === "done") {
|
||||||
|
toast.success("Экспорт медиа успешно создан");
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
snapshotStore.snapshotStatus = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await getStorageInfo();
|
||||||
|
navigate(-1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Ошибка при создании экспорта медиа");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isLoading || !name.trim()}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -209,45 +87,6 @@ export const SnapshotCreatePage = observer(() => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={duplicateWarningOpen}
|
|
||||||
onClose={() => !isLoading && setDuplicateWarningOpen(false)}
|
|
||||||
maxWidth="sm"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<DialogTitle>Найдены повторяющиеся маршруты</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<p className="mb-3">
|
|
||||||
Обнаружены маршруты с одинаковыми номерами трассы. Это может привести к
|
|
||||||
некорректным данным в экспорте.
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc pl-5">
|
|
||||||
{duplicateRouteNumbers.map((msg, i) => (
|
|
||||||
<li key={i}>{msg}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
onClick={() => setDuplicateWarningOpen(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
sx={{ backgroundColor: "#795548", "&:hover": { backgroundColor: "#5D4037" } }}
|
|
||||||
disabled={isLoading}
|
|
||||||
onClick={async () => {
|
|
||||||
setDuplicateWarningOpen(false);
|
|
||||||
await startExport();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Продолжить экспорт
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user