sync demo layout with client 1.0.8

This commit is contained in:
2026-08-08 09:20:24 +03:00
parent cc38f2e66c
commit 8e8eb625dd
31 changed files with 551 additions and 2833 deletions

26
.gitignore vendored
View File

@@ -1,14 +1,7 @@
# Logs
<<<<<<< HEAD
logs
_.log
npm-debug.log_
=======
logs
*.log
npm-debug.log*
>>>>>>> 6b9aa78 (init: Init React Application)
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
@@ -17,23 +10,11 @@ lerna-debug.log*
node_modules
dist
dist-ssr
<<<<<<< HEAD
\*.local
# Editor directories and files
.vscode/_
!.vscode/extensions.json
.idea
.DS_Store
_.suo
_.ntvs_
_.njsproj
_.sln
\*.sw?
=======
*.local
# Build artifacts
*.tsbuildinfo
# Editor directories and files
.vscode/*
!.vscode/extensions.json
@@ -44,4 +25,3 @@ _.sln
*.njsproj
*.sln
*.sw?
>>>>>>> 6b9aa78 (init: Init React Application)

View File

@@ -18,7 +18,6 @@
"@mui/material": "^7.1.0",
"@mui/x-data-grid": "^8.5.1",
"@photo-sphere-viewer/core": "^5.13.2",
"@pixi/react": "^8.0.2",
"@react-three/drei": "^10.1.2",
"@react-three/fiber": "^9.1.2",
"@tailwindcss/vite": "^4.1.8",
@@ -33,7 +32,6 @@
"overlayscrollbars": "^2.15.1",
"overlayscrollbars-react": "^0.5.6",
"path": "^0.12.7",
"pixi.js": "^8.10.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",

View File

@@ -95,9 +95,10 @@ const ListOfSights = observer(() => {
// Находим все достопримечательности, начинающиеся с выбранной буквы
const sightsForLetter = sightData.filter((sight) => {
if (!sight.name) return false;
const name = sight.name;
if (!name) return false;
let sightFirstChar = sight.name.trim().charAt(0);
let sightFirstChar = name.trim().charAt(0);
// Для китайского языка используем пиньинь или иероглиф
if (selectedLanguageRight === "zh") {
@@ -105,7 +106,7 @@ const ListOfSights = observer(() => {
sightFirstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
} else {
// Если пиньинь нет, используем первый иероглиф
sightFirstChar = sight.name.trim().charAt(0);
sightFirstChar = name.trim().charAt(0);
}
} else {
sightFirstChar = sightFirstChar.toUpperCase();
@@ -440,11 +441,6 @@ const ListOfSights = observer(() => {
isLangOpen={isLangMenuOpen}
/>
<TransferWidget
isOpen={isTransferWidgetOpen}
selectedLanguageRight={selectedLanguage}
/>
<LanguageSelector
selectedLanguageRight={selectedLanguageRight}
onLanguageChange={handleLanguageChange}
@@ -476,6 +472,12 @@ const ListOfSights = observer(() => {
isDisabled={isAlphabetDisabled}
/>
</div>
</div>
<TransferWidget
isOpen={isTransferWidgetOpen}
selectedLanguageRight={selectedLanguage}
/>
<div
className="transfer-button-container"
@@ -513,7 +515,6 @@ const ListOfSights = observer(() => {
</div>
</div>
</div>
</div>
);
});

View File

@@ -14,8 +14,9 @@ const AlphabetNavigator = forwardRef(function AlphabetNavigator(
const letters = new Set();
sightData.forEach((sight) => {
if (sight.name && sight.name.trim()) {
let firstChar = sight.name.trim().charAt(0);
const name = sight.short_name || sight.name;
if (name && name.trim()) {
let firstChar = name.trim().charAt(0);
// Для китайского языка используем пиньинь (если доступен) или иероглиф
if (selectedLanguage === "zh") {
@@ -23,7 +24,7 @@ const AlphabetNavigator = forwardRef(function AlphabetNavigator(
firstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
} else {
// Если пиньинь нет, используем первый иероглиф
firstChar = sight.name.trim().charAt(0);
firstChar = name.trim().charAt(0);
}
} else {
firstChar = firstChar.toUpperCase();

View File

@@ -41,16 +41,35 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const [isFullscreen3D, setIsFullscreen3D] = useState(false);
const [fullscreenFileUrl, setFullscreenFileUrl] = useState("");
const [threeViewResetKey, setThreeViewResetKey] = useState(0);
const [isSwitching, setIsSwitching] = useState(false);
const threeViewControlRef = useRef(null);
const mediaCache = useRef({});
const idleTimerRef = useRef(null);
const prevSightIdRef = 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;
@@ -83,55 +102,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const totalChildrenWidth = children.reduce((sum, el) => sum + el.offsetWidth, 0);
const evenGap = (availableWidth - totalChildrenWidth) / (children.length + 1);
setMenuNeedsScroll(evenGap < 10);
}, [articleSections, selectedSection]);
// Автозакрытие fullscreen 3D при бездействии (60 сек)
useEffect(() => {
if (!isFullscreen3D) {
if (idleTimerRef.current) {
clearInterval(idleTimerRef.current);
idleTimerRef.current = null;
}
return;
}
let idleSeconds = 0;
const checkIdle = () => {
idleSeconds += 1;
if (idleSeconds >= 60) {
setIsFullscreen3D(false);
}
};
idleTimerRef.current = 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 () => {
if (idleTimerRef.current) {
clearInterval(idleTimerRef.current);
idleTimerRef.current = null;
}
events.forEach((event) => {
window.removeEventListener(event, resetIdle);
});
};
}, [isFullscreen3D]);
}, [articleSections]);
const {
routeSights,
@@ -171,11 +142,16 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const controller = new AbortController();
const { signal } = controller;
const isSameSight = prevSightIdRef.current === sight_id;
prevSightIdRef.current = sight_id;
if (!isSameSight) {
setIsLoadingContent(true);
setContentError(null);
setModelAspectRatio(null);
setIsVisible(false);
setMediaData({});
}
if (!sight_id) {
setContentError("Не указан ID статьи.");
@@ -253,15 +229,18 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const introSection = {
id: media?.id || "intro-title",
heading:
sight?.short_name ||
sight?.name ||
sight_name ||
"Название достопримечательности",
sight?.name || sight_name || "Название достопримечательности",
body: "",
};
const allSections = [introSection, ...rightArticles];
setArticleSections(allSections);
if (isSameSight) {
setIsLoadingContent(false);
setIsVisible(true);
return;
}
const cacheKey = `${sight_id}_${selectedLanguageRight}`;
if (mediaCache.current[cacheKey]) {
setMediaData(mediaCache.current[cacheKey]);
@@ -302,9 +281,43 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
useEffect(() => {
setSelectedSection(0);
setIsSwitching(false);
if (switchTimerRef.current) {
clearTimeout(switchTimerRef.current);
switchTimerRef.current = null;
}
}, [sight_id]);
const currentSection = articleSections?.[selectedSection] ?? null;
useLayoutEffect(() => {
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 = () => {
if (!articleSections || Object.keys(mediaData).length === 0) {
@@ -600,7 +613,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const processedSightName = useMemo(() => {
if (!sight_name) return sight_name;
// Handle \n line breaks (только в правом виджете)
if (sight_name.includes("\n")) {
return sight_name.split("\n").map((line, i) => (
<React.Fragment key={i}>
@@ -645,6 +657,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
}, [sight_name]);
return (
<>
<div
className={`sight-frame ${isVisible ? "is-visible" : ""} ${
isFullscreen3D && isCurrentMedia3D ? "three-d-fullscreen" : ""
@@ -654,6 +667,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
<Watermark path={ContentAPI.getMediaPath(sightData.watermark_lu)} />
)}
<div
ref={mediaStackRef}
className={`sight-frame-media-stack ${
isCurrentMedia3D ? "three-d-view" : ""
}`}
@@ -671,21 +685,22 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
renderCurrentMedia()
)}
</div>
<div className="sight-frame-content">
<div className={`sight-frame-content ${isSwitching ? "is-switching" : ""}`}>
{contentError ? (
<p className="error-message">{contentError}</p>
) : !currentSection ? (
<p>Информация отсутствует.</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",
@@ -693,62 +708,45 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
}}
>
{selectedSection === 0
? processedSightName
: sightData?.short_name || sight_name}
? processedSightName || sightData?.name
: sightData?.short_name || sightData?.name}
</p>
</div>
)}
{selectedSection !== 0 && (
<TouchableLayout
className="sight-frame-text-wrapper"
key={selectedSection}
className={`sight-frame-text-wrapper ${selectedSection === 0 ? "is-intro" : ""}`}
ref={textWrapperRef}
maxHeight="calc(80vh - 354px)"
>
<div className="sight-frame-text">
<div className="sight-frame-text fade-in-text">
{isLoadingContent || !currentSection ? (
<div className="sight-frame-loading-placeholder" />
) : (
selectedSection !== 0 && (
<ReactMarkdownComponent value={currentSection.body} />
)
)}
</div>
</TouchableLayout>
)}
</>
)}
</div>
<div className="sight-frame-menu-wrapper">
</div>
<div
className="sight-frame-menu-wrapper"
style={{
opacity: isVisible ? 1 : 0,
transition: "opacity 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.08s",
pointerEvents: isVisible ? "auto" : "none",
}}
>
<div className="sight-frame-menu-fade left" style={{ opacity: canScrollLeft ? 1 : 0 }} />
<div className="sight-frame-menu-fade right" style={{ opacity: canScrollRight ? 1 : 0 }} />
<div
className="sight-frame-menu"
ref={menuRef}
style={menuNeedsScroll ? { justifyContent: 'space-between' } : undefined}
style={menuNeedsScroll ? { justifyContent: 'flex-start' } : undefined}
>
<div
style={{
position: "absolute",
left: "10px",
marginTop: "-4.5px",
zIndex: 1,
paddingLeft: "15px",
paddingRight: "7.5px",
paddingTop: "4.5px",
paddingBottom: "4.5px",
cursor: "pointer",
opacity: selectedSection !== 0 ? 1 : 0,
transform: selectedSection !== 0 ? "scale(1)" : "scale(0.5)",
transition: "opacity 0.3s ease, transform 0.3s ease",
pointerEvents: selectedSection !== 0 ? "auto" : "none",
}}
onPointerUp={() => {
setSelectedSection(0);
setIsFullscreen3D(false);
}}
>
<img
src={subtractHomeIcon}
alt=""
width="24"
height="21"
style={{ display: "block" }}
/>
</div>
{contentError ? (
<p className="error-message">{contentError}</p>
) : (
@@ -756,11 +754,9 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
articleSections.length > 1 &&
articleSections.slice(1).map((section, index) => (
<div
onPointerUp={() => {
setSelectedSection(index + 1);
setIsFullscreen3D(false);
}}
onPointerUp={() => requestSection(index + 1)}
key={section.id || section.heading || index}
data-label={section.heading}
className={`sight-frame-menu-point ${
index + 1 === selectedSection ? "active" : ""
}`}
@@ -773,7 +769,33 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
)}
</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>
</>
);
});

View File

@@ -95,35 +95,35 @@ const TransferWidget = observer(function TransferWidget({
}
const getTransferLabel = () => {
if (!stationName) {
if (selectedLanguageRight === "en") return "Nearest station not found";
if (selectedLanguageRight === "zh") return "最近的站点未找到";
return "Ближайшая остановка не обнаружена";
if (selectedLanguageRight === "ru") {
return stationName ? (
<>
<div>Пересадка на остановке</div>
<div>«{stationName}»:</div>
</>
) : (
"Ближайшая остановка не обнаружена"
);
}
if (selectedLanguageRight === "en") {
return (
return stationName ? (
<>
Transfer at stop<br />
«{stationName}»:
<div>Transfer at stop</div>
<div>«{stationName}»:</div>
</>
) : (
"Nearest station not found"
);
}
if (selectedLanguageRight === "zh") {
return (
return stationName ? (
<>
换乘<br />
«{stationName}»:
</>
);
}
return (
<>
Пересадка на остановке<br />
«{stationName}»:
<div>在站点换乘</div>
<div>«{stationName}»:</div>
</>
) : (
"最近的站点未找到"
);
};

View File

@@ -12,6 +12,7 @@ interface TouchableLayoutProps {
children?: ReactNode;
className?: string;
maxHeight?: string | number;
style?: React.CSSProperties;
}
function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
@@ -23,13 +24,16 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
isAtBottom: false,
});
const [visible, setVisible] = useState(false);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rafRef = useRef<number | null>(null);
const update = useCallback(() => {
const el = scrollableRef.current;
if (!el) return;
if (el.closest('[data-height-animating="true"]')) {
return;
}
const sh = el.scrollHeight;
const ch = el.clientHeight;
const st = el.scrollTop;
@@ -38,7 +42,7 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
const isAtTop = st <= 0;
const isAtBottom = st + ch >= sh - 1;
if (sh <= ch) {
if (sh <= ch + 2) {
setState((prev) => ({ ...prev, hasScroll: false, isAtTop: true, isAtBottom: true }));
return;
}
@@ -64,39 +68,36 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
};
el.addEventListener("scroll", schedule, { passive: true });
el.addEventListener("layoutchange", schedule);
const ro = new ResizeObserver(schedule);
ro.observe(el);
const mo = new MutationObserver(schedule);
mo.observe(el, { childList: true, subtree: true, characterData: true });
schedule();
return () => {
el.removeEventListener("scroll", schedule);
el.removeEventListener("layoutchange", schedule);
ro.disconnect();
mo.disconnect();
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
};
}, [update]);
useEffect(() => {
if (state.hasScroll) {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
setVisible(true);
} else {
hideTimerRef.current = setTimeout(() => {
if (!state.hasScroll) {
setVisible(false);
}, 200);
return;
}
return () => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
};
const t = setTimeout(() => setVisible(true), 250);
return () => clearTimeout(t);
}, [state.hasScroll]);
return { ...state, visible };
}
export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
({ children, className, maxHeight }, ref) => {
({ children, className, maxHeight, style }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const scrollableRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
@@ -273,7 +274,7 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
: {};
return (
<div ref={setRefs} className={containerClassName}>
<div ref={setRefs} className={containerClassName} style={style}>
<div className="scrollable-viewport" style={viewportStyle}>
<div ref={scrollableRef} className="scrollable">
{children}
@@ -281,15 +282,18 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
<div
ref={trackRef}
className="custom-scrollbar-track"
style={{ opacity: thumb.hasScroll ? 1 : 0 }}
style={{ opacity: thumb.visible ? 1 : 0 }}
>
{thumb.visible && (
<div
ref={thumbRef}
className="custom-scrollbar-thumb"
style={{ height: thumb.height, top: thumb.top }}
style={{
height: thumb.height,
top: thumb.top,
opacity: thumb.visible ? 1 : 0,
pointerEvents: thumb.visible ? "auto" : "none",
}}
/>
)}
</div>
</div>
</div>

View File

@@ -8,6 +8,7 @@ import windIcon from "../assets/weather-icons/wind.png";
import humidityIcon from "../assets/weather-icons/humidity.png";
import { useGeolocationStore } from "../stores";
import { translateWeatherStatus } from "../utils/translateWeatherStatus";
import { apiStore } from "../api/ApiStore";
function WeatherDataLine({ icon, value, unit }) {
return (
@@ -43,7 +44,11 @@ const WeatherWidget = observer(() => {
const fetchWeather = async () => {
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);
setError(null);
setHasLoadedOnce(true);

View File

@@ -1,349 +0,0 @@
import { FederatedPointerEvent, FederatedWheelEvent } from "pixi.js";
import { ReactNode, useEffect, useState, useRef } from "react";
import { useTransform } from "./transformContext";
import { BACKGROUND_COLOR, SCALE_FACTOR } from "../../assets/Constants";
import { useApplication } from "@pixi/react";
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,
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 [, setInitialPinchDistance] = useState<number | null>(null);
const [, 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 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>
</>
);
}
);

View File

@@ -5,8 +5,6 @@ import {
useMemo,
} from "react";
import { observer } from "mobx-react-lite";
import { extend } from "@pixi/react";
import { Container, Graphics, Sprite, Text } from "pixi.js";
import { MapDataProvider, useMapData } from "./MapDataContext";
import { TransformProvider, useTransform } from "./transformContext";
// @ts-ignore
@@ -19,8 +17,6 @@ import { SCALE_FACTOR } from "../../assets/Constants";
import { apiStore } from "../../api/ApiStore/store";
import WebGLMap from "./WebGLMap";
extend({ Container, Graphics, Text, Sprite });
export function Map() {
return (
<MapDataProvider>

View File

@@ -1,393 +0,0 @@
// SightsLayer.tsx
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;
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;
}) {
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;
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>) {
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}
/>
);
})}
</>
);
}

View File

@@ -1,174 +0,0 @@
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>
);
}
);

View File

@@ -1,375 +0,0 @@
import { Texture, Assets } 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;
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>
);
}

View File

@@ -1,92 +0,0 @@
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} />;
}

View File

@@ -0,0 +1,39 @@
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;

View File

@@ -4,6 +4,7 @@ import { useEffect, useState, useCallback, useRef } from "react";
import { observer } from "mobx-react-lite";
import sideMenuPhoto from "/side-menu-photo.png";
import RouteWidget from "../widgets/RouteWidget";
import WeatherWidget from "../WeatherWidget";
import ContentAPI from "../../api/content/content.api";
import { useGeolocationStore, useColorStore } from "../../stores";
import "../../styles/LeftWidget.css";
@@ -85,7 +86,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
let scrollTarget = null;
const targetElement = document.elementFromPoint(
touch.clientX,
touch.clientY
touch.clientY,
);
// Определяем, над каким из скролл-контейнеров находится палец
@@ -263,18 +264,17 @@ const SideMenu = observer(({ onMenuToggle }) => {
}
};
const isMenuOpenRef = useRef(isMenuOpen);
const handleMenuToggleRef = useRef(handleMenuToggle);
useEffect(() => { isMenuOpenRef.current = isMenuOpen; }, [isMenuOpen]);
useEffect(() => { handleMenuToggleRef.current = handleMenuToggle; });
handleMenuToggleRef.current = handleMenuToggle;
const isMenuOpenRef = useRef(isMenuOpen);
isMenuOpenRef.current = isMenuOpen;
useEffect(() => {
// Автоматическое закрытие сайд-меню после 60 секунд бездействия
let idleSeconds = 0;
const checkIdle = () => {
idleSeconds += 1;
if (idleSeconds >= 60 && isMenuOpenRef.current) {
handleMenuToggleRef.current(false);
}
@@ -307,6 +307,47 @@ const SideMenu = observer(({ onMenuToggle }) => {
};
}, []);
// Автоматическое скрытие левого виджета через 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 isFirstRenderRef = useRef(true);
@@ -498,7 +539,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
}, 300);
}
}}
className={`side-menu-button ${
className={`side-menu-button side-menu-button--sights ${
isSightsOpen ? "side-menu-button--active" : ""
}`}
>
@@ -583,11 +624,26 @@ const SideMenu = observer(({ onMenuToggle }) => {
<div className="side-menu-control-panel">
<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
widgetImgPath={(() => {
const m = sightArticles.get(route?.governor_appeal + "_ru")?.media;
const mediaId = Array.isArray(m) ? m[0]?.id : m?.id;
return mediaId ? getMediaUrl(mediaId) : undefined;
return mediaId ? ContentAPI.getMediaPath(mediaId) : undefined;
})()}
isOpen={isWidgetOpen}
style={{

View File

@@ -10,6 +10,7 @@ import { useGeolocationStore } from "../../stores";
import { apiStore } from "../../api/ApiStore/store";
import { useClickDetection } from "../../hooks/useClickDetection";
import { TouchableLayout } from "../TouchableLayout";
import Collapsible from "./Collapsible";
import { getMediaUrl } from "../../api/apiConfig";
import stationIcon from "../../assets/transport-icons/station.svg";
@@ -38,25 +39,8 @@ const SightItem = ({
const [shouldAnimate, setShouldAnimate] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
// Получаем название для отображения
const getSightName = () => {
// Если есть короткое название, используем его
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 sight.short_name || sight.name;
};
const sightName = getSightName();
@@ -84,12 +68,6 @@ const SightItem = ({
return () => window.removeEventListener("resize", checkWidth);
}, [sightName]);
useEffect(() => {
if (localSelectedSightId !== sight.id) {
setIsExpanded(false);
}
}, [localSelectedSightId, sight.id]);
const handleClick = (e) => {
const newExpanded = !isExpanded;
setIsExpanded(newExpanded);
@@ -101,13 +79,31 @@ const SightItem = ({
const cacheKey = `${sight.id}_${selectedLanguage}`;
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 (
<div
className={
localSelectedSightId === sight.id
? "side-menu-sight-selected-wrapper"
: ""
}
className={`side-menu-sight-wrapper ${
localSelectedSightId === sight.id ? "side-menu-sight-selected-wrapper" : ""
}`}
>
<div
ref={containerRef}
@@ -120,12 +116,11 @@ const SightItem = ({
{sightName}
</span>
</div>
<div
className={`side-menu-sight-transfer-list ${isExpanded ? "open" : ""}`}
>
<Collapsible className="side-menu-sight-transfer-list" open={isExpanded}>
{stations.length > 0 ? (
stations.map((station, index) => {
const iconSrc = isMediaIdEmpty(station.icon)
let isMediaIdEmptyResult = isMediaIdEmpty(station.icon);
const iconSrc = isMediaIdEmptyResult
? stationIcon
: getMediaUrl(station.icon);
@@ -166,7 +161,7 @@ const SightItem = ({
: "No stations"}
</div>
)}
</div>
</Collapsible>
</div>
);
};
@@ -242,24 +237,8 @@ const SightsList = observer(
: selectedLanguage === "en"
? routeSightsEn
: routeSightsZh;
// Функция для получения названия для сортировки
const getSightNameForSort = (sight) => {
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();
return (sight.short_name || sight.name).trim();
};
const sortedSights = [...(sights || [])].sort((a, b) => {
@@ -358,25 +337,18 @@ const SightsList = observer(
}, [localSelectedSightId, sightList, className]);
const handleSightClick = (sightId) => {
// Если кликнули на уже выбранную достопримечательность — закрываем виджет и сбрасываем цвет
if (isLeftWidgetOpen && localSelectedSightId === sightId) {
setLocalSelectedSightId(null);
setIsLeftWidgetOpen(false);
return;
}
// Открываем левый виджет
if (isLeftWidgetOpen && localSelectedSightId !== sightId) {
setLocalSelectedSightId(sightId);
setIsLeftWidgetOpen(true);
if (onSightSelected) {
onSightSelected(sightId);
}
} else if (!isLeftWidgetOpen) {
setLocalSelectedSightId(sightId);
setIsLeftWidgetOpen(true);
if (onSightSelected) {
onSightSelected(sightId);
}
} else {
// Если виджет уже открыт для этой достопримечательности, просто обновляем список остановок
if (onSightSelected) {
onSightSelected(sightId);
}
}
};
const handleClose = () => {

View File

@@ -28,10 +28,10 @@ const SightItem = ({
const textRef = useRef(null);
const [shouldAnimate, setShouldAnimate] = useState(false);
// Получаем название из left_article
// Получаем название из left_article или используем short_name/name
const getSightName = () => {
if (!sight.left_article) {
return sight.name;
return sight.short_name || sight.name;
}
const leftArticleData =
@@ -41,7 +41,7 @@ const SightItem = ({
? sightArticlesEn.get(sight.left_article + "_en")
: sightArticlesZh.get(sight.left_article + "_zh");
return leftArticleData?.heading || sight.name;
return leftArticleData?.heading || sight.short_name || sight.name;
};
const sightName = getSightName();
@@ -119,10 +119,9 @@ const StationSightsList = observer(
selectedLanguage
);
// Функция для получения названия из left_article
const getSightNameForSort = (sight) => {
if (!sight.left_article) {
return sight.name.trim();
return (sight.short_name || sight.name).trim();
}
const leftArticleData =
@@ -132,7 +131,7 @@ const StationSightsList = observer(
? sightArticlesEn.get(sight.left_article + "_en")
: sightArticlesZh.get(sight.left_article + "_zh");
return (leftArticleData?.heading || sight.name).trim();
return (leftArticleData?.heading || sight.short_name || sight.name).trim();
};
const sortedSights = [...(response || [])].sort((a, b) => {
@@ -310,3 +309,4 @@ const StationSightsList = observer(
);
export default StationSightsList;
nSightsList;

View File

@@ -10,6 +10,7 @@ import { useGeolocationStore } from "../../stores";
import { apiStore } from "../../api/ApiStore/store";
import { useClickDetection } from "../../hooks/useClickDetection";
import { TouchableLayout } from "../TouchableLayout";
import Collapsible from "./Collapsible";
const SightTransferItem = ({ name, style, onPointerUp }) => {
const containerRef = useRef(null);
@@ -92,31 +93,14 @@ const StationItem = ({
const sights = stationSightsCache.get(cacheKey) || [];
const getSightName = (sight) => {
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 sight.short_name || sight.name;
};
return (
<div
className={
selectedStationId === station.id
? "side-menu-sight-selected-wrapper"
: ""
}
className={`side-menu-sight-wrapper ${
selectedStationId === station.id ? "side-menu-sight-selected-wrapper" : ""
}`}
>
<div
ref={containerRef}
@@ -135,10 +119,9 @@ const StationItem = ({
{station.name}
</span>
</div>
<div
className={`side-menu-sight-transfer-list ${
selectedStationId === station.id ? "open" : ""
}`}
<Collapsible
className="side-menu-sight-transfer-list"
open={selectedStationId === station.id}
>
{sights.length > 0 ? (
sights.map((sight, index) => (
@@ -171,7 +154,7 @@ const StationItem = ({
: "No sights"}
</div>
)}
</div>
</Collapsible>
</div>
);
};

View File

@@ -8,7 +8,7 @@ import { apiStore } from "../../api/ApiStore/store";
const RouteWidget = observer(() => {
const store = useGeolocationStore();
const { contextData, isLoading, error, selectedLanguage } = store;
const { routeStations, routeStationsEn, routeStationsZh, context, route } = apiStore;
const { routeStations, routeStationsEn, routeStationsZh, context } = apiStore;
const [startStation, setStartStation] = useState(null);
const [startStationEn, setStartStationEn] = useState(null);
const [endStation, setEndStation] = useState(null);
@@ -84,8 +84,8 @@ const RouteWidget = observer(() => {
const routeZhSubtitle = `${startStationZh?.name} - ${endStationZh?.name}`;
return (
<div className="route-widget">
<div className={`route-widget-number ${getNumberSizeClass(route?.route_sys_number || context?.routeNumber)}`}>
{route?.route_sys_number || context?.routeNumber || ""}
<div className={`route-widget-number ${getNumberSizeClass(context?.routeNumber)}`}>
{context?.routeNumber || "No number"}
</div>
<div className="route-widget-content">
<div

View File

@@ -166,24 +166,15 @@
animation: side-menu-marquee 14s linear infinite;
}
/* Анимация для списка пересадок */
.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;
}
.side-menu-sight-transfer-list {
max-height: 0;
height: 0;
opacity: 0;
overflow: hidden;
transition: max-height 0.3s ease-out, opacity 0.3s ease-out; /* Анимация при открытии/закрытии */
transition:
height 0.3s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.3s ease;
}
/* Активное состояние - когда список открыт */
.side-menu-sight-transfer-list.open {
max-height: 500px; /* Достаточно большое значение, чтобы вместить все пересадки */
opacity: 1;
}

View File

@@ -48,6 +48,8 @@
}
.list-of-sights {
position: relative;
z-index: 10;
-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);
box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
@@ -63,7 +65,7 @@
color: white;
max-height: 68px;
transition: max-height 0.15s ease;
transition: max-height 0.35s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
}
@@ -105,11 +107,7 @@
border-radius: 10px;
width: 128px;
background-color: color-mix(
in srgb,
var(--carrier-right, #806c59) 80%,
black
);
background-color: var(--carrier-right-dark, #005a2f);
}
.list-of-sights-title {
@@ -127,7 +125,7 @@
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease-in-out 0.15s;
transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1) 0.1s;
overscroll-behavior: contain;
touch-action: pan-y;
@@ -146,7 +144,9 @@
black calc(100% - var(--fade-bottom)),
transparent 100%
);
transition: --fade-top 0.5s ease, --fade-bottom 0.5s ease;
transition:
--fade-top 0.5s ease,
--fade-bottom 0.5s ease;
}
.list-of-sights-content:not(.is-at-top) .scrollable {
@@ -171,6 +171,7 @@
}
.list-of-sights-content .custom-scrollbar-track {
margin-top: 14px;
margin-bottom: 10px;
overflow: hidden;
}
@@ -247,18 +248,18 @@
height: 1px;
width: 100%;
background-color: rgba(255, 255, 255, 0.3);
margin-bottom: 14px;
margin-bottom: 0;
}
.sight-frame {
z-index: -1;
position: absolute;
bottom: 66px;
bottom: 100px;
left: 0;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32px;
width: 550px;
width: 100%;
border-radius: 10px;
background:
linear-gradient(
@@ -268,6 +269,12 @@
),
var(--carrier-right, #806c59);
max-height: calc(100vh - 128px);
transform: translate3d(0, 0, 0);
overflow: hidden;
}
.sight-frame.three-d-fullscreen {
overflow: visible;
}
.sight-frame-image {
@@ -284,6 +291,11 @@
flex-direction: column;
flex-grow: 1;
width: 100%;
transition: opacity 0.18s ease;
}
.sight-frame-content.is-switching {
opacity: 0;
}
.sight-frame-get-back-wrapper {
@@ -314,10 +326,22 @@
box-sizing: border-box;
color: white;
word-wrap: break-word;
overflow-wrap: break-word;
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(
@@ -345,18 +369,40 @@
}
.sight-frame-text-wrapper {
flex-grow: 1;
padding: 16px;
box-sizing: border-box;
max-height: calc(80vh - 354px);
min-height: 0;
flex-shrink: 0;
padding: 16px 16px 76px 16px;
box-sizing: border-box;
overflow: hidden;
display: flex;
flex-direction: column;
transition: opacity 0.3s ease;
}
.sight-frame-text-wrapper.scrollable-container {
padding: 16px;
padding: 16px 16px 76px 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 {
flex: 1;
@@ -367,11 +413,12 @@
}
.sight-frame-text {
display: flow-root; /* BFC prevents margin collapsing for correct scrollHeight measurement */
padding-right: 10px;
text-align: left;
color: #fff;
font-family: "Roboto";
font-size: 18px;
font-size: 20px;
font-style: normal;
font-weight: 400;
line-height: 150%;
@@ -386,7 +433,7 @@
background: linear-gradient(
to right,
transparent 35%,
color-mix(in srgb, var(--carrier-right, #806c59) 80%, black) 50%,
#0e8953 50%,
transparent 65%
);
border-radius: 3px;
@@ -409,8 +456,14 @@
}
.sight-frame-menu-wrapper {
position: relative;
position: absolute;
bottom: 100px;
left: 0;
width: 100%;
height: 60px;
z-index: 2;
border-radius: 0 0 10px 10px;
overflow: hidden;
flex-shrink: 0;
}
@@ -419,7 +472,7 @@
top: 0;
bottom: 0;
width: 120px;
z-index: 3;
z-index: 100001;
pointer-events: none;
transition: opacity 0.4s ease;
}
@@ -445,7 +498,7 @@
}
.sight-frame-menu {
z-index: 10000;
z-index: 100000;
position: relative;
padding: 7px 60px;
width: 100%;
@@ -496,7 +549,7 @@
}
.sight-frame-menu-point.active {
font-weight: 600;
text-shadow: 0 0 0.4px #fff, 0 0 0.4px #fff;
border-bottom-color: #fff;
}
@@ -522,30 +575,20 @@
}
.sight-frame {
opacity: 0;
transform: translateY(20px);
transition:
opacity 0.2s ease-out,
transform 0.2s ease-out;
transition: opacity 0.9s ease-out;
}
.sight-frame.is-visible {
opacity: 1;
transform: translateY(0);
animation: fadeInScale 0.6s ease-out forwards;
animation: fadeInScale 0.9s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes fadeInScale {
0% {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
50% {
opacity: 0.7;
transform: translateY(-5px) scale(1.02);
}
100% {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@@ -597,6 +640,7 @@
font-size: 40px;
font-weight: 600;
line-height: 150%;
padding-bottom: 60px;
}
.svg-container {
@@ -654,15 +698,17 @@
width: 40px;
flex-shrink: 0;
margin-right: 10px;
padding-top: 24px;
margin-top: 14px;
margin-bottom: 10px;
padding-top: 10px;
padding-bottom: 10px;
display: flex;
align-items: center;
flex-direction: column;
gap: 16px;
gap: 20px;
overflow-y: auto;
overflow-x: hidden;
height: 650px;
padding-bottom: 30px;
height: 676px;
touch-action: pan-y;
overscroll-behavior: contain;
scrollbar-width: none;
@@ -881,7 +927,7 @@
border-radius: 32px;
right: 20px;
bottom: 20px;
background: var(--carrier-right, #806c59);
background: var(--carrier-main, #006f3a);
z-index: 9999;
display: flex;
}

View File

@@ -52,8 +52,7 @@
height: 96px;
background-color: #fcd500;
color: black;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
border-radius: 10px 0 0 10px;
display: flex;
justify-content: center;
align-items: center;

View File

@@ -41,7 +41,6 @@
.side-menu-buttons {
width: 220px;
margin-top: 40px;
}
.side-menu-button {
@@ -198,6 +197,8 @@
overflow: hidden;
top: 250px;
bottom: 0;
display: flex;
flex-direction: column;
border-radius: 10px 10px 0px 0px;
background:
linear-gradient(
@@ -213,8 +214,6 @@
transition:
transform 0.3s ease-out,
opacity 0.3s ease-out;
display: flex;
flex-direction: column;
}
.side-menu-sights.slide-in {
@@ -253,10 +252,15 @@
position: relative;
}
.side-menu-sight-selected-wrapper {
background: rgba(0, 0, 0, 0.2);
.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 {

View File

@@ -43,7 +43,8 @@
position: relative;
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
transition: opacity 0.2s ease;
overflow: hidden;
transition: opacity 0.5s ease;
}
.custom-scrollbar-thumb {
@@ -56,6 +57,7 @@
pointer-events: auto;
cursor: grab;
touch-action: none;
transition: opacity 0.5s ease, height 0.5s ease;
}
.custom-scrollbar-thumb:active {

View File

@@ -1,232 +0,0 @@
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>
);
}

View File

@@ -1,137 +0,0 @@
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>
);
};

View File

@@ -1,557 +0,0 @@
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}
>
{ruLabelWidth > 0 && (
<pixiGraphics
draw={(g: Graphics) => {
g.clear();
const hasSecondLabel = !!(station.name && language !== "ru" && ruLabel);
const pad = 10 / scale;
const w = ruLabelWidth + pad * 2;
const top = -compensatedRuFontSize / 2 - pad;
const bottom = hasSecondLabel
? compensatedRuFontSize * 1.1 + compensatedNameFontSize / 2 + pad
: compensatedRuFontSize / 2 + pad;
g.rect(-w / 2, top, w, bottom - top);
g.fill({ color: 0x000000, alpha: 0.001 });
}}
/>
)}
{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>
);
};

View File

@@ -1,34 +0,0 @@
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} />;
}

View File

@@ -1,14 +1,5 @@
import { useRef, useEffect, useState } from "react";
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 { MapDataProvider, useMapData } from "./MapDataContext";
import { TransformProvider, useTransform } from "./TransformContext";
@@ -22,15 +13,6 @@ import { UP_SCALE } from "./Constants";
import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype";
import { CircularProgress } from "@mui/material";
extend({
Container,
Graphics,
Sprite,
Texture,
TilingSprite,
Text,
});
const Loading = () => {
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
@@ -172,25 +154,6 @@ export const RouteMap = observer(() => {
return (
<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 />
</div>
);

File diff suppressed because one or more lines are too long