sync demo layout with client 1.0.8
This commit is contained in:
26
.gitignore
vendored
26
.gitignore
vendored
@@ -1,14 +1,7 @@
|
|||||||
# Logs
|
# Logs
|
||||||
<<<<<<< HEAD
|
|
||||||
|
|
||||||
logs
|
|
||||||
_.log
|
|
||||||
npm-debug.log_
|
|
||||||
=======
|
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
>>>>>>> 6b9aa78 (init: Init React Application)
|
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
@@ -17,23 +10,11 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
<<<<<<< HEAD
|
|
||||||
\*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
|
|
||||||
.vscode/_
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
_.suo
|
|
||||||
_.ntvs_
|
|
||||||
_.njsproj
|
|
||||||
_.sln
|
|
||||||
\*.sw?
|
|
||||||
=======
|
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
@@ -44,4 +25,3 @@ _.sln
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
>>>>>>> 6b9aa78 (init: Init React Application)
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"@mui/material": "^7.1.0",
|
"@mui/material": "^7.1.0",
|
||||||
"@mui/x-data-grid": "^8.5.1",
|
"@mui/x-data-grid": "^8.5.1",
|
||||||
"@photo-sphere-viewer/core": "^5.13.2",
|
"@photo-sphere-viewer/core": "^5.13.2",
|
||||||
"@pixi/react": "^8.0.2",
|
|
||||||
"@react-three/drei": "^10.1.2",
|
"@react-three/drei": "^10.1.2",
|
||||||
"@react-three/fiber": "^9.1.2",
|
"@react-three/fiber": "^9.1.2",
|
||||||
"@tailwindcss/vite": "^4.1.8",
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
@@ -33,7 +32,6 @@
|
|||||||
"overlayscrollbars": "^2.15.1",
|
"overlayscrollbars": "^2.15.1",
|
||||||
"overlayscrollbars-react": "^0.5.6",
|
"overlayscrollbars-react": "^0.5.6",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
"pixi.js": "^8.10.1",
|
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
@@ -95,9 +95,10 @@ const ListOfSights = observer(() => {
|
|||||||
|
|
||||||
// Находим все достопримечательности, начинающиеся с выбранной буквы
|
// Находим все достопримечательности, начинающиеся с выбранной буквы
|
||||||
const sightsForLetter = sightData.filter((sight) => {
|
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") {
|
if (selectedLanguageRight === "zh") {
|
||||||
@@ -105,7 +106,7 @@ const ListOfSights = observer(() => {
|
|||||||
sightFirstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
|
sightFirstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
|
||||||
} else {
|
} else {
|
||||||
// Если пиньинь нет, используем первый иероглиф
|
// Если пиньинь нет, используем первый иероглиф
|
||||||
sightFirstChar = sight.name.trim().charAt(0);
|
sightFirstChar = name.trim().charAt(0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sightFirstChar = sightFirstChar.toUpperCase();
|
sightFirstChar = sightFirstChar.toUpperCase();
|
||||||
@@ -440,11 +441,6 @@ const ListOfSights = observer(() => {
|
|||||||
isLangOpen={isLangMenuOpen}
|
isLangOpen={isLangMenuOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TransferWidget
|
|
||||||
isOpen={isTransferWidgetOpen}
|
|
||||||
selectedLanguageRight={selectedLanguage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<LanguageSelector
|
<LanguageSelector
|
||||||
selectedLanguageRight={selectedLanguageRight}
|
selectedLanguageRight={selectedLanguageRight}
|
||||||
onLanguageChange={handleLanguageChange}
|
onLanguageChange={handleLanguageChange}
|
||||||
@@ -476,6 +472,12 @@ const ListOfSights = observer(() => {
|
|||||||
isDisabled={isAlphabetDisabled}
|
isDisabled={isAlphabetDisabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TransferWidget
|
||||||
|
isOpen={isTransferWidgetOpen}
|
||||||
|
selectedLanguageRight={selectedLanguage}
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="transfer-button-container"
|
className="transfer-button-container"
|
||||||
@@ -513,7 +515,6 @@ const ListOfSights = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ const AlphabetNavigator = forwardRef(function AlphabetNavigator(
|
|||||||
const letters = new Set();
|
const letters = new Set();
|
||||||
|
|
||||||
sightData.forEach((sight) => {
|
sightData.forEach((sight) => {
|
||||||
if (sight.name && sight.name.trim()) {
|
const name = sight.short_name || sight.name;
|
||||||
let firstChar = sight.name.trim().charAt(0);
|
if (name && name.trim()) {
|
||||||
|
let firstChar = name.trim().charAt(0);
|
||||||
|
|
||||||
// Для китайского языка используем пиньинь (если доступен) или иероглиф
|
// Для китайского языка используем пиньинь (если доступен) или иероглиф
|
||||||
if (selectedLanguage === "zh") {
|
if (selectedLanguage === "zh") {
|
||||||
@@ -23,7 +24,7 @@ const AlphabetNavigator = forwardRef(function AlphabetNavigator(
|
|||||||
firstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
|
firstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
|
||||||
} else {
|
} else {
|
||||||
// Если пиньинь нет, используем первый иероглиф
|
// Если пиньинь нет, используем первый иероглиф
|
||||||
firstChar = sight.name.trim().charAt(0);
|
firstChar = name.trim().charAt(0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
firstChar = firstChar.toUpperCase();
|
firstChar = firstChar.toUpperCase();
|
||||||
|
|||||||
@@ -41,16 +41,35 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
const [isFullscreen3D, setIsFullscreen3D] = useState(false);
|
const [isFullscreen3D, setIsFullscreen3D] = useState(false);
|
||||||
const [fullscreenFileUrl, setFullscreenFileUrl] = useState("");
|
const [fullscreenFileUrl, setFullscreenFileUrl] = useState("");
|
||||||
const [threeViewResetKey, setThreeViewResetKey] = useState(0);
|
const [threeViewResetKey, setThreeViewResetKey] = useState(0);
|
||||||
|
const [isSwitching, setIsSwitching] = useState(false);
|
||||||
const threeViewControlRef = useRef(null);
|
const threeViewControlRef = useRef(null);
|
||||||
const mediaCache = useRef({});
|
const mediaCache = useRef({});
|
||||||
const idleTimerRef = useRef(null);
|
const prevSightIdRef = useRef(null);
|
||||||
|
|
||||||
const textWrapperRef = useRef(null);
|
const textWrapperRef = useRef(null);
|
||||||
|
const mediaStackRef = useRef(null);
|
||||||
|
const titleRef = useRef(null);
|
||||||
|
const switchTimerRef = useRef(null);
|
||||||
|
const homeButtonRef = useRef(null);
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
const [menuNeedsScroll, setMenuNeedsScroll] = useState(false);
|
const [menuNeedsScroll, setMenuNeedsScroll] = useState(false);
|
||||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||||
const [canScrollRight, setCanScrollRight] = 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 updateScrollState = useCallback(() => {
|
||||||
const menu = menuRef.current;
|
const menu = menuRef.current;
|
||||||
if (!menu) return;
|
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 totalChildrenWidth = children.reduce((sum, el) => sum + el.offsetWidth, 0);
|
||||||
const evenGap = (availableWidth - totalChildrenWidth) / (children.length + 1);
|
const evenGap = (availableWidth - totalChildrenWidth) / (children.length + 1);
|
||||||
setMenuNeedsScroll(evenGap < 10);
|
setMenuNeedsScroll(evenGap < 10);
|
||||||
}, [articleSections, selectedSection]);
|
}, [articleSections]);
|
||||||
|
|
||||||
// Автозакрытие 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]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
routeSights,
|
routeSights,
|
||||||
@@ -171,11 +142,16 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const { signal } = controller;
|
const { signal } = controller;
|
||||||
|
|
||||||
|
const isSameSight = prevSightIdRef.current === sight_id;
|
||||||
|
prevSightIdRef.current = sight_id;
|
||||||
|
|
||||||
|
if (!isSameSight) {
|
||||||
setIsLoadingContent(true);
|
setIsLoadingContent(true);
|
||||||
setContentError(null);
|
setContentError(null);
|
||||||
setModelAspectRatio(null);
|
setModelAspectRatio(null);
|
||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
setMediaData({});
|
setMediaData({});
|
||||||
|
}
|
||||||
|
|
||||||
if (!sight_id) {
|
if (!sight_id) {
|
||||||
setContentError("Не указан ID статьи.");
|
setContentError("Не указан ID статьи.");
|
||||||
@@ -253,15 +229,18 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
const introSection = {
|
const introSection = {
|
||||||
id: media?.id || "intro-title",
|
id: media?.id || "intro-title",
|
||||||
heading:
|
heading:
|
||||||
sight?.short_name ||
|
sight?.name || sight_name || "Название достопримечательности",
|
||||||
sight?.name ||
|
|
||||||
sight_name ||
|
|
||||||
"Название достопримечательности",
|
|
||||||
body: "",
|
body: "",
|
||||||
};
|
};
|
||||||
const allSections = [introSection, ...rightArticles];
|
const allSections = [introSection, ...rightArticles];
|
||||||
setArticleSections(allSections);
|
setArticleSections(allSections);
|
||||||
|
|
||||||
|
if (isSameSight) {
|
||||||
|
setIsLoadingContent(false);
|
||||||
|
setIsVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cacheKey = `${sight_id}_${selectedLanguageRight}`;
|
const cacheKey = `${sight_id}_${selectedLanguageRight}`;
|
||||||
if (mediaCache.current[cacheKey]) {
|
if (mediaCache.current[cacheKey]) {
|
||||||
setMediaData(mediaCache.current[cacheKey]);
|
setMediaData(mediaCache.current[cacheKey]);
|
||||||
@@ -302,9 +281,43 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedSection(0);
|
setSelectedSection(0);
|
||||||
|
setIsSwitching(false);
|
||||||
|
if (switchTimerRef.current) {
|
||||||
|
clearTimeout(switchTimerRef.current);
|
||||||
|
switchTimerRef.current = null;
|
||||||
|
}
|
||||||
}, [sight_id]);
|
}, [sight_id]);
|
||||||
|
|
||||||
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 = () => {
|
const renderCurrentMedia = () => {
|
||||||
if (!articleSections || Object.keys(mediaData).length === 0) {
|
if (!articleSections || Object.keys(mediaData).length === 0) {
|
||||||
@@ -600,7 +613,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
const processedSightName = useMemo(() => {
|
const processedSightName = useMemo(() => {
|
||||||
if (!sight_name) return sight_name;
|
if (!sight_name) return sight_name;
|
||||||
|
|
||||||
// Handle \n line breaks (только в правом виджете)
|
|
||||||
if (sight_name.includes("\n")) {
|
if (sight_name.includes("\n")) {
|
||||||
return sight_name.split("\n").map((line, i) => (
|
return sight_name.split("\n").map((line, i) => (
|
||||||
<React.Fragment key={i}>
|
<React.Fragment key={i}>
|
||||||
@@ -645,6 +657,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
}, [sight_name]);
|
}, [sight_name]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className={`sight-frame ${isVisible ? "is-visible" : ""} ${
|
className={`sight-frame ${isVisible ? "is-visible" : ""} ${
|
||||||
isFullscreen3D && isCurrentMedia3D ? "three-d-fullscreen" : ""
|
isFullscreen3D && isCurrentMedia3D ? "three-d-fullscreen" : ""
|
||||||
@@ -654,6 +667,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
<Watermark path={ContentAPI.getMediaPath(sightData.watermark_lu)} />
|
<Watermark path={ContentAPI.getMediaPath(sightData.watermark_lu)} />
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
ref={mediaStackRef}
|
||||||
className={`sight-frame-media-stack ${
|
className={`sight-frame-media-stack ${
|
||||||
isCurrentMedia3D ? "three-d-view" : ""
|
isCurrentMedia3D ? "three-d-view" : ""
|
||||||
}`}
|
}`}
|
||||||
@@ -671,21 +685,22 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
renderCurrentMedia()
|
renderCurrentMedia()
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="sight-frame-content">
|
<div className={`sight-frame-content ${isSwitching ? "is-switching" : ""}`}>
|
||||||
{contentError ? (
|
{contentError ? (
|
||||||
<p className="error-message">{contentError}</p>
|
<p className="error-message">{contentError}</p>
|
||||||
) : !currentSection ? (
|
|
||||||
<p>Информация отсутствует.</p>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!isFullscreen3D && (
|
{!isFullscreen3D && (
|
||||||
<div
|
<div
|
||||||
|
ref={titleRef}
|
||||||
className={`sight-frame-title ${
|
className={`sight-frame-title ${
|
||||||
selectedSection === 0 ? "intro-title" : ""
|
selectedSection === 0 ? "intro-title" : ""
|
||||||
}`}
|
}`}
|
||||||
style={{ lineHeight: titleLineHeight }}
|
style={{ lineHeight: titleLineHeight }}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
|
key={selectedSection}
|
||||||
|
className="fade-in-text"
|
||||||
style={{
|
style={{
|
||||||
whiteSpace: "normal",
|
whiteSpace: "normal",
|
||||||
wordBreak: "break-word",
|
wordBreak: "break-word",
|
||||||
@@ -693,62 +708,45 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedSection === 0
|
{selectedSection === 0
|
||||||
? processedSightName
|
? processedSightName || sightData?.name
|
||||||
: sightData?.short_name || sight_name}
|
: sightData?.short_name || sightData?.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedSection !== 0 && (
|
|
||||||
<TouchableLayout
|
<TouchableLayout
|
||||||
className="sight-frame-text-wrapper"
|
key={selectedSection}
|
||||||
|
className={`sight-frame-text-wrapper ${selectedSection === 0 ? "is-intro" : ""}`}
|
||||||
ref={textWrapperRef}
|
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} />
|
<ReactMarkdownComponent value={currentSection.body} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TouchableLayout>
|
</TouchableLayout>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 left" style={{ opacity: canScrollLeft ? 1 : 0 }} />
|
||||||
<div className="sight-frame-menu-fade right" style={{ opacity: canScrollRight ? 1 : 0 }} />
|
<div className="sight-frame-menu-fade right" style={{ opacity: canScrollRight ? 1 : 0 }} />
|
||||||
<div
|
<div
|
||||||
className="sight-frame-menu"
|
className="sight-frame-menu"
|
||||||
ref={menuRef}
|
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 ? (
|
{contentError ? (
|
||||||
<p className="error-message">{contentError}</p>
|
<p className="error-message">{contentError}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -756,11 +754,9 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
articleSections.length > 1 &&
|
articleSections.length > 1 &&
|
||||||
articleSections.slice(1).map((section, index) => (
|
articleSections.slice(1).map((section, index) => (
|
||||||
<div
|
<div
|
||||||
onPointerUp={() => {
|
onPointerUp={() => requestSection(index + 1)}
|
||||||
setSelectedSection(index + 1);
|
|
||||||
setIsFullscreen3D(false);
|
|
||||||
}}
|
|
||||||
key={section.id || section.heading || index}
|
key={section.id || section.heading || index}
|
||||||
|
data-label={section.heading}
|
||||||
className={`sight-frame-menu-point ${
|
className={`sight-frame-menu-point ${
|
||||||
index + 1 === selectedSection ? "active" : ""
|
index + 1 === selectedSection ? "active" : ""
|
||||||
}`}
|
}`}
|
||||||
@@ -773,7 +769,33 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
ref={homeButtonRef}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "10px",
|
||||||
|
bottom: "115px",
|
||||||
|
zIndex: 3,
|
||||||
|
paddingLeft: "15px",
|
||||||
|
paddingRight: "7.5px",
|
||||||
|
paddingTop: "4.5px",
|
||||||
|
paddingBottom: "4.5px",
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: selectedSection !== 0 ? 1 : 0,
|
||||||
|
transition: "opacity 0.3s ease",
|
||||||
|
pointerEvents: selectedSection !== 0 ? "auto" : "none",
|
||||||
|
}}
|
||||||
|
onPointerUp={() => requestSection(0)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={subtractHomeIcon}
|
||||||
|
alt=""
|
||||||
|
width="24"
|
||||||
|
height="21"
|
||||||
|
style={{ display: "block" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -95,35 +95,35 @@ const TransferWidget = observer(function TransferWidget({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getTransferLabel = () => {
|
const getTransferLabel = () => {
|
||||||
if (!stationName) {
|
if (selectedLanguageRight === "ru") {
|
||||||
if (selectedLanguageRight === "en") return "Nearest station not found";
|
return stationName ? (
|
||||||
if (selectedLanguageRight === "zh") return "最近的站点未找到";
|
<>
|
||||||
return "Ближайшая остановка не обнаружена";
|
<div>Пересадка на остановке</div>
|
||||||
|
<div>«{stationName}»:</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Ближайшая остановка не обнаружена"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedLanguageRight === "en") {
|
if (selectedLanguageRight === "en") {
|
||||||
return (
|
return stationName ? (
|
||||||
<>
|
<>
|
||||||
Transfer at stop<br />
|
<div>Transfer at stop</div>
|
||||||
«{stationName}»:
|
<div>«{stationName}»:</div>
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
"Nearest station not found"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedLanguageRight === "zh") {
|
return stationName ? (
|
||||||
return (
|
|
||||||
<>
|
<>
|
||||||
换乘站<br />
|
<div>在站点换乘</div>
|
||||||
«{stationName}»:
|
<div>«{stationName}»:</div>
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
Пересадка на остановке<br />
|
|
||||||
«{stationName}»:
|
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
"最近的站点未找到"
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface TouchableLayoutProps {
|
|||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
maxHeight?: string | number;
|
maxHeight?: string | number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
||||||
@@ -23,13 +24,16 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
|||||||
isAtBottom: false,
|
isAtBottom: false,
|
||||||
});
|
});
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const update = useCallback(() => {
|
const update = useCallback(() => {
|
||||||
const el = scrollableRef.current;
|
const el = scrollableRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
|
if (el.closest('[data-height-animating="true"]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const sh = el.scrollHeight;
|
const sh = el.scrollHeight;
|
||||||
const ch = el.clientHeight;
|
const ch = el.clientHeight;
|
||||||
const st = el.scrollTop;
|
const st = el.scrollTop;
|
||||||
@@ -38,7 +42,7 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
|||||||
const isAtTop = st <= 0;
|
const isAtTop = st <= 0;
|
||||||
const isAtBottom = st + ch >= sh - 1;
|
const isAtBottom = st + ch >= sh - 1;
|
||||||
|
|
||||||
if (sh <= ch) {
|
if (sh <= ch + 2) {
|
||||||
setState((prev) => ({ ...prev, hasScroll: false, isAtTop: true, isAtBottom: true }));
|
setState((prev) => ({ ...prev, hasScroll: false, isAtTop: true, isAtBottom: true }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -64,39 +68,36 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
el.addEventListener("scroll", schedule, { passive: true });
|
el.addEventListener("scroll", schedule, { passive: true });
|
||||||
|
el.addEventListener("layoutchange", schedule);
|
||||||
const ro = new ResizeObserver(schedule);
|
const ro = new ResizeObserver(schedule);
|
||||||
ro.observe(el);
|
ro.observe(el);
|
||||||
|
const mo = new MutationObserver(schedule);
|
||||||
|
mo.observe(el, { childList: true, subtree: true, characterData: true });
|
||||||
schedule();
|
schedule();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener("scroll", schedule);
|
el.removeEventListener("scroll", schedule);
|
||||||
|
el.removeEventListener("layoutchange", schedule);
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
|
mo.disconnect();
|
||||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||||
};
|
};
|
||||||
}, [update]);
|
}, [update]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.hasScroll) {
|
if (!state.hasScroll) {
|
||||||
if (hideTimerRef.current) {
|
|
||||||
clearTimeout(hideTimerRef.current);
|
|
||||||
hideTimerRef.current = null;
|
|
||||||
}
|
|
||||||
setVisible(true);
|
|
||||||
} else {
|
|
||||||
hideTimerRef.current = setTimeout(() => {
|
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
}, 200);
|
return;
|
||||||
}
|
}
|
||||||
return () => {
|
const t = setTimeout(() => setVisible(true), 250);
|
||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
return () => clearTimeout(t);
|
||||||
};
|
|
||||||
}, [state.hasScroll]);
|
}, [state.hasScroll]);
|
||||||
|
|
||||||
return { ...state, visible };
|
return { ...state, visible };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
||||||
({ children, className, maxHeight }, ref) => {
|
({ children, className, maxHeight, style }, ref) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||||
const trackRef = useRef<HTMLDivElement>(null);
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -273,7 +274,7 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
|||||||
: {};
|
: {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setRefs} className={containerClassName}>
|
<div ref={setRefs} className={containerClassName} style={style}>
|
||||||
<div className="scrollable-viewport" style={viewportStyle}>
|
<div className="scrollable-viewport" style={viewportStyle}>
|
||||||
<div ref={scrollableRef} className="scrollable">
|
<div ref={scrollableRef} className="scrollable">
|
||||||
{children}
|
{children}
|
||||||
@@ -281,15 +282,18 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
|||||||
<div
|
<div
|
||||||
ref={trackRef}
|
ref={trackRef}
|
||||||
className="custom-scrollbar-track"
|
className="custom-scrollbar-track"
|
||||||
style={{ opacity: thumb.hasScroll ? 1 : 0 }}
|
style={{ opacity: thumb.visible ? 1 : 0 }}
|
||||||
>
|
>
|
||||||
{thumb.visible && (
|
|
||||||
<div
|
<div
|
||||||
ref={thumbRef}
|
ref={thumbRef}
|
||||||
className="custom-scrollbar-thumb"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import windIcon from "../assets/weather-icons/wind.png";
|
|||||||
import humidityIcon from "../assets/weather-icons/humidity.png";
|
import humidityIcon from "../assets/weather-icons/humidity.png";
|
||||||
import { useGeolocationStore } from "../stores";
|
import { useGeolocationStore } from "../stores";
|
||||||
import { translateWeatherStatus } from "../utils/translateWeatherStatus";
|
import { translateWeatherStatus } from "../utils/translateWeatherStatus";
|
||||||
|
import { apiStore } from "../api/ApiStore";
|
||||||
|
|
||||||
function WeatherDataLine({ icon, value, unit }) {
|
function WeatherDataLine({ icon, value, unit }) {
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +44,11 @@ const WeatherWidget = observer(() => {
|
|||||||
|
|
||||||
const fetchWeather = async () => {
|
const fetchWeather = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await weatherApi.getFormattedWeather();
|
const data = await weatherApi.getFormattedWeather({
|
||||||
|
lat: apiStore.context?.currentCoordinates?.latitude,
|
||||||
|
lng: apiStore.context?.currentCoordinates?.longitude,
|
||||||
|
cityCode: apiStore.city?.weather_city_code,
|
||||||
|
});
|
||||||
setWeather(data);
|
setWeather(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
setHasLoadedOnce(true);
|
setHasLoadedOnce(true);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
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 { MapDataProvider, useMapData } from "./MapDataContext";
|
||||||
import { TransformProvider, useTransform } from "./transformContext";
|
import { TransformProvider, useTransform } from "./transformContext";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -19,8 +17,6 @@ import { SCALE_FACTOR } from "../../assets/Constants";
|
|||||||
import { apiStore } from "../../api/ApiStore/store";
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
import WebGLMap from "./WebGLMap";
|
import WebGLMap from "./WebGLMap";
|
||||||
|
|
||||||
extend({ Container, Graphics, Text, Sprite });
|
|
||||||
|
|
||||||
export function Map() {
|
export function Map() {
|
||||||
return (
|
return (
|
||||||
<MapDataProvider>
|
<MapDataProvider>
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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} />;
|
|
||||||
}
|
|
||||||
39
src/client/src/components/side-menu/Collapsible.jsx
Normal file
39
src/client/src/components/side-menu/Collapsible.jsx
Normal 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;
|
||||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useCallback, useRef } from "react";
|
|||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import sideMenuPhoto from "/side-menu-photo.png";
|
import sideMenuPhoto from "/side-menu-photo.png";
|
||||||
import RouteWidget from "../widgets/RouteWidget";
|
import RouteWidget from "../widgets/RouteWidget";
|
||||||
|
import WeatherWidget from "../WeatherWidget";
|
||||||
import ContentAPI from "../../api/content/content.api";
|
import ContentAPI from "../../api/content/content.api";
|
||||||
import { useGeolocationStore, useColorStore } from "../../stores";
|
import { useGeolocationStore, useColorStore } from "../../stores";
|
||||||
import "../../styles/LeftWidget.css";
|
import "../../styles/LeftWidget.css";
|
||||||
@@ -85,7 +86,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
let scrollTarget = null;
|
let scrollTarget = null;
|
||||||
const targetElement = document.elementFromPoint(
|
const targetElement = document.elementFromPoint(
|
||||||
touch.clientX,
|
touch.clientX,
|
||||||
touch.clientY
|
touch.clientY,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Определяем, над каким из скролл-контейнеров находится палец
|
// Определяем, над каким из скролл-контейнеров находится палец
|
||||||
@@ -263,18 +264,17 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMenuOpenRef = useRef(isMenuOpen);
|
|
||||||
const handleMenuToggleRef = useRef(handleMenuToggle);
|
const handleMenuToggleRef = useRef(handleMenuToggle);
|
||||||
useEffect(() => { isMenuOpenRef.current = isMenuOpen; }, [isMenuOpen]);
|
handleMenuToggleRef.current = handleMenuToggle;
|
||||||
useEffect(() => { handleMenuToggleRef.current = handleMenuToggle; });
|
|
||||||
|
const isMenuOpenRef = useRef(isMenuOpen);
|
||||||
|
isMenuOpenRef.current = isMenuOpen;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Автоматическое закрытие сайд-меню после 60 секунд бездействия
|
|
||||||
let idleSeconds = 0;
|
let idleSeconds = 0;
|
||||||
|
|
||||||
const checkIdle = () => {
|
const checkIdle = () => {
|
||||||
idleSeconds += 1;
|
idleSeconds += 1;
|
||||||
|
|
||||||
if (idleSeconds >= 60 && isMenuOpenRef.current) {
|
if (idleSeconds >= 60 && isMenuOpenRef.current) {
|
||||||
handleMenuToggleRef.current(false);
|
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 prevSortingByRef = useRef(sortingBy);
|
||||||
const isFirstRenderRef = useRef(true);
|
const isFirstRenderRef = useRef(true);
|
||||||
@@ -498,7 +539,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`side-menu-button ${
|
className={`side-menu-button side-menu-button--sights ${
|
||||||
isSightsOpen ? "side-menu-button--active" : ""
|
isSightsOpen ? "side-menu-button--active" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -507,7 +548,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
: selectedLanguage == "zh"
|
: selectedLanguage == "zh"
|
||||||
? "景点"
|
? "景点"
|
||||||
: "Attractions"}
|
: "Attractions"}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onPointerUp={() => {
|
onPointerUp={() => {
|
||||||
if (!isStationOpen) {
|
if (!isStationOpen) {
|
||||||
@@ -555,7 +596,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
: selectedLanguage == "zh"
|
: selectedLanguage == "zh"
|
||||||
? "车站"
|
? "车站"
|
||||||
: "Stations"}
|
: "Stations"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="side-menu-tag">
|
<div className="side-menu-tag">
|
||||||
{/* {selectedLanguage == "ru"
|
{/* {selectedLanguage == "ru"
|
||||||
@@ -583,11 +624,26 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
<div className="side-menu-control-panel">
|
<div className="side-menu-control-panel">
|
||||||
<RouteWidget />
|
<RouteWidget />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
transform:
|
||||||
|
isWeatherVisible && !isGovernorWidgetOpen
|
||||||
|
? "translateX(0)"
|
||||||
|
: "translateX(-200%)",
|
||||||
|
transition: "transform 1s ease",
|
||||||
|
opacity: isWeatherVisible && !isGovernorWidgetOpen ? 1 : 0,
|
||||||
|
pointerEvents:
|
||||||
|
isWeatherVisible && !isGovernorWidgetOpen ? "auto" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WeatherWidget />
|
||||||
|
</div>
|
||||||
|
|
||||||
<AppealWidget
|
<AppealWidget
|
||||||
widgetImgPath={(() => {
|
widgetImgPath={(() => {
|
||||||
const m = sightArticles.get(route?.governor_appeal + "_ru")?.media;
|
const m = sightArticles.get(route?.governor_appeal + "_ru")?.media;
|
||||||
const mediaId = Array.isArray(m) ? m[0]?.id : m?.id;
|
const mediaId = Array.isArray(m) ? m[0]?.id : m?.id;
|
||||||
return mediaId ? getMediaUrl(mediaId) : undefined;
|
return mediaId ? ContentAPI.getMediaPath(mediaId) : undefined;
|
||||||
})()}
|
})()}
|
||||||
isOpen={isWidgetOpen}
|
isOpen={isWidgetOpen}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useGeolocationStore } from "../../stores";
|
|||||||
import { apiStore } from "../../api/ApiStore/store";
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
import { useClickDetection } from "../../hooks/useClickDetection";
|
import { useClickDetection } from "../../hooks/useClickDetection";
|
||||||
import { TouchableLayout } from "../TouchableLayout";
|
import { TouchableLayout } from "../TouchableLayout";
|
||||||
|
import Collapsible from "./Collapsible";
|
||||||
import { getMediaUrl } from "../../api/apiConfig";
|
import { getMediaUrl } from "../../api/apiConfig";
|
||||||
import stationIcon from "../../assets/transport-icons/station.svg";
|
import stationIcon from "../../assets/transport-icons/station.svg";
|
||||||
|
|
||||||
@@ -38,25 +39,8 @@ const SightItem = ({
|
|||||||
const [shouldAnimate, setShouldAnimate] = useState(false);
|
const [shouldAnimate, setShouldAnimate] = useState(false);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
// Получаем название для отображения
|
|
||||||
const getSightName = () => {
|
const getSightName = () => {
|
||||||
// Если есть короткое название, используем его
|
return sight.short_name || sight.name;
|
||||||
if (sight.short_name) {
|
|
||||||
return sight.short_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sight.left_article) {
|
|
||||||
return sight.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
const leftArticleData =
|
|
||||||
selectedLanguage === "ru"
|
|
||||||
? sightArticles.get(sight.left_article + "_ru")
|
|
||||||
: selectedLanguage === "en"
|
|
||||||
? sightArticlesEn.get(sight.left_article + "_en")
|
|
||||||
: sightArticlesZh.get(sight.left_article + "_zh");
|
|
||||||
|
|
||||||
return leftArticleData?.heading || sight.name;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const sightName = getSightName();
|
const sightName = getSightName();
|
||||||
@@ -84,12 +68,6 @@ const SightItem = ({
|
|||||||
return () => window.removeEventListener("resize", checkWidth);
|
return () => window.removeEventListener("resize", checkWidth);
|
||||||
}, [sightName]);
|
}, [sightName]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (localSelectedSightId !== sight.id) {
|
|
||||||
setIsExpanded(false);
|
|
||||||
}
|
|
||||||
}, [localSelectedSightId, sight.id]);
|
|
||||||
|
|
||||||
const handleClick = (e) => {
|
const handleClick = (e) => {
|
||||||
const newExpanded = !isExpanded;
|
const newExpanded = !isExpanded;
|
||||||
setIsExpanded(newExpanded);
|
setIsExpanded(newExpanded);
|
||||||
@@ -101,13 +79,31 @@ const SightItem = ({
|
|||||||
const cacheKey = `${sight.id}_${selectedLanguage}`;
|
const cacheKey = `${sight.id}_${selectedLanguage}`;
|
||||||
const stations = sightStationsCache.get(cacheKey) || [];
|
const stations = sightStationsCache.get(cacheKey) || [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (localSelectedSightId !== sight.id) {
|
||||||
|
setIsExpanded(false);
|
||||||
|
}
|
||||||
|
}, [localSelectedSightId, sight.id]);
|
||||||
|
|
||||||
|
// Логирование при открытии списка станций для достопримечательности (только один раз)
|
||||||
|
const wasExpandedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
// Выводим логи только когда список открывается (переход из закрытого в открытое состояние)
|
||||||
|
if (isExpanded && !wasExpandedRef.current && stations.length > 0) {
|
||||||
|
wasExpandedRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сбрасываем флаг когда список закрывается
|
||||||
|
if (!isExpanded) {
|
||||||
|
wasExpandedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [isExpanded, sight.id, selectedLanguage, stations.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={`side-menu-sight-wrapper ${
|
||||||
localSelectedSightId === sight.id
|
localSelectedSightId === sight.id ? "side-menu-sight-selected-wrapper" : ""
|
||||||
? "side-menu-sight-selected-wrapper"
|
}`}
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -120,12 +116,11 @@ const SightItem = ({
|
|||||||
{sightName}
|
{sightName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Collapsible className="side-menu-sight-transfer-list" open={isExpanded}>
|
||||||
className={`side-menu-sight-transfer-list ${isExpanded ? "open" : ""}`}
|
|
||||||
>
|
|
||||||
{stations.length > 0 ? (
|
{stations.length > 0 ? (
|
||||||
stations.map((station, index) => {
|
stations.map((station, index) => {
|
||||||
const iconSrc = isMediaIdEmpty(station.icon)
|
let isMediaIdEmptyResult = isMediaIdEmpty(station.icon);
|
||||||
|
const iconSrc = isMediaIdEmptyResult
|
||||||
? stationIcon
|
? stationIcon
|
||||||
: getMediaUrl(station.icon);
|
: getMediaUrl(station.icon);
|
||||||
|
|
||||||
@@ -166,7 +161,7 @@ const SightItem = ({
|
|||||||
: "No stations"}
|
: "No stations"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -242,24 +237,8 @@ const SightsList = observer(
|
|||||||
: selectedLanguage === "en"
|
: selectedLanguage === "en"
|
||||||
? routeSightsEn
|
? routeSightsEn
|
||||||
: routeSightsZh;
|
: routeSightsZh;
|
||||||
// Функция для получения названия для сортировки
|
|
||||||
const getSightNameForSort = (sight) => {
|
const getSightNameForSort = (sight) => {
|
||||||
if (sight.short_name) {
|
return (sight.short_name || sight.name).trim();
|
||||||
return sight.short_name.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sight.left_article) {
|
|
||||||
return sight.name.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const leftArticleData =
|
|
||||||
selectedLanguage === "ru"
|
|
||||||
? sightArticles.get(sight.left_article + "_ru")
|
|
||||||
: selectedLanguage === "en"
|
|
||||||
? sightArticlesEn.get(sight.left_article + "_en")
|
|
||||||
: sightArticlesZh.get(sight.left_article + "_zh");
|
|
||||||
|
|
||||||
return (leftArticleData?.heading || sight.name).trim();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedSights = [...(sights || [])].sort((a, b) => {
|
const sortedSights = [...(sights || [])].sort((a, b) => {
|
||||||
@@ -358,25 +337,18 @@ const SightsList = observer(
|
|||||||
}, [localSelectedSightId, sightList, className]);
|
}, [localSelectedSightId, sightList, className]);
|
||||||
|
|
||||||
const handleSightClick = (sightId) => {
|
const handleSightClick = (sightId) => {
|
||||||
|
// Если кликнули на уже выбранную достопримечательность — закрываем виджет и сбрасываем цвет
|
||||||
|
if (isLeftWidgetOpen && localSelectedSightId === sightId) {
|
||||||
|
setLocalSelectedSightId(null);
|
||||||
|
setIsLeftWidgetOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Открываем левый виджет
|
// Открываем левый виджет
|
||||||
if (isLeftWidgetOpen && localSelectedSightId !== sightId) {
|
|
||||||
setLocalSelectedSightId(sightId);
|
setLocalSelectedSightId(sightId);
|
||||||
setIsLeftWidgetOpen(true);
|
setIsLeftWidgetOpen(true);
|
||||||
if (onSightSelected) {
|
if (onSightSelected) {
|
||||||
onSightSelected(sightId);
|
onSightSelected(sightId);
|
||||||
}
|
}
|
||||||
} else if (!isLeftWidgetOpen) {
|
|
||||||
setLocalSelectedSightId(sightId);
|
|
||||||
setIsLeftWidgetOpen(true);
|
|
||||||
if (onSightSelected) {
|
|
||||||
onSightSelected(sightId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Если виджет уже открыт для этой достопримечательности, просто обновляем список остановок
|
|
||||||
if (onSightSelected) {
|
|
||||||
onSightSelected(sightId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ const SightItem = ({
|
|||||||
const textRef = useRef(null);
|
const textRef = useRef(null);
|
||||||
const [shouldAnimate, setShouldAnimate] = useState(false);
|
const [shouldAnimate, setShouldAnimate] = useState(false);
|
||||||
|
|
||||||
// Получаем название из left_article
|
// Получаем название из left_article или используем short_name/name
|
||||||
const getSightName = () => {
|
const getSightName = () => {
|
||||||
if (!sight.left_article) {
|
if (!sight.left_article) {
|
||||||
return sight.name;
|
return sight.short_name || sight.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftArticleData =
|
const leftArticleData =
|
||||||
@@ -41,7 +41,7 @@ const SightItem = ({
|
|||||||
? sightArticlesEn.get(sight.left_article + "_en")
|
? sightArticlesEn.get(sight.left_article + "_en")
|
||||||
: sightArticlesZh.get(sight.left_article + "_zh");
|
: sightArticlesZh.get(sight.left_article + "_zh");
|
||||||
|
|
||||||
return leftArticleData?.heading || sight.name;
|
return leftArticleData?.heading || sight.short_name || sight.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sightName = getSightName();
|
const sightName = getSightName();
|
||||||
@@ -119,10 +119,9 @@ const StationSightsList = observer(
|
|||||||
selectedLanguage
|
selectedLanguage
|
||||||
);
|
);
|
||||||
|
|
||||||
// Функция для получения названия из left_article
|
|
||||||
const getSightNameForSort = (sight) => {
|
const getSightNameForSort = (sight) => {
|
||||||
if (!sight.left_article) {
|
if (!sight.left_article) {
|
||||||
return sight.name.trim();
|
return (sight.short_name || sight.name).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftArticleData =
|
const leftArticleData =
|
||||||
@@ -132,7 +131,7 @@ const StationSightsList = observer(
|
|||||||
? sightArticlesEn.get(sight.left_article + "_en")
|
? sightArticlesEn.get(sight.left_article + "_en")
|
||||||
: sightArticlesZh.get(sight.left_article + "_zh");
|
: sightArticlesZh.get(sight.left_article + "_zh");
|
||||||
|
|
||||||
return (leftArticleData?.heading || sight.name).trim();
|
return (leftArticleData?.heading || sight.short_name || sight.name).trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedSights = [...(response || [])].sort((a, b) => {
|
const sortedSights = [...(response || [])].sort((a, b) => {
|
||||||
@@ -310,3 +309,4 @@ const StationSightsList = observer(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default StationSightsList;
|
export default StationSightsList;
|
||||||
|
nSightsList;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useGeolocationStore } from "../../stores";
|
|||||||
import { apiStore } from "../../api/ApiStore/store";
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
import { useClickDetection } from "../../hooks/useClickDetection";
|
import { useClickDetection } from "../../hooks/useClickDetection";
|
||||||
import { TouchableLayout } from "../TouchableLayout";
|
import { TouchableLayout } from "../TouchableLayout";
|
||||||
|
import Collapsible from "./Collapsible";
|
||||||
|
|
||||||
const SightTransferItem = ({ name, style, onPointerUp }) => {
|
const SightTransferItem = ({ name, style, onPointerUp }) => {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
@@ -92,31 +93,14 @@ const StationItem = ({
|
|||||||
const sights = stationSightsCache.get(cacheKey) || [];
|
const sights = stationSightsCache.get(cacheKey) || [];
|
||||||
|
|
||||||
const getSightName = (sight) => {
|
const getSightName = (sight) => {
|
||||||
if (sight.short_name) {
|
return sight.short_name || sight.name;
|
||||||
return sight.short_name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sight.left_article) {
|
|
||||||
return sight.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
const leftArticleData =
|
|
||||||
selectedLanguage === "ru"
|
|
||||||
? sightArticles.get(sight.left_article + "_ru")
|
|
||||||
: selectedLanguage === "en"
|
|
||||||
? sightArticlesEn.get(sight.left_article + "_en")
|
|
||||||
: sightArticlesZh.get(sight.left_article + "_zh");
|
|
||||||
|
|
||||||
return leftArticleData?.heading || sight.name;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={`side-menu-sight-wrapper ${
|
||||||
selectedStationId === station.id
|
selectedStationId === station.id ? "side-menu-sight-selected-wrapper" : ""
|
||||||
? "side-menu-sight-selected-wrapper"
|
}`}
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -135,10 +119,9 @@ const StationItem = ({
|
|||||||
{station.name}
|
{station.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Collapsible
|
||||||
className={`side-menu-sight-transfer-list ${
|
className="side-menu-sight-transfer-list"
|
||||||
selectedStationId === station.id ? "open" : ""
|
open={selectedStationId === station.id}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{sights.length > 0 ? (
|
{sights.length > 0 ? (
|
||||||
sights.map((sight, index) => (
|
sights.map((sight, index) => (
|
||||||
@@ -171,7 +154,7 @@ const StationItem = ({
|
|||||||
: "No sights"}
|
: "No sights"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { apiStore } from "../../api/ApiStore/store";
|
|||||||
const RouteWidget = observer(() => {
|
const RouteWidget = observer(() => {
|
||||||
const store = useGeolocationStore();
|
const store = useGeolocationStore();
|
||||||
const { contextData, isLoading, error, selectedLanguage } = store;
|
const { contextData, isLoading, error, selectedLanguage } = store;
|
||||||
const { routeStations, routeStationsEn, routeStationsZh, context, route } = apiStore;
|
const { routeStations, routeStationsEn, routeStationsZh, context } = apiStore;
|
||||||
const [startStation, setStartStation] = useState(null);
|
const [startStation, setStartStation] = useState(null);
|
||||||
const [startStationEn, setStartStationEn] = useState(null);
|
const [startStationEn, setStartStationEn] = useState(null);
|
||||||
const [endStation, setEndStation] = useState(null);
|
const [endStation, setEndStation] = useState(null);
|
||||||
@@ -84,22 +84,22 @@ const RouteWidget = observer(() => {
|
|||||||
const routeZhSubtitle = `${startStationZh?.name} - ${endStationZh?.name}`;
|
const routeZhSubtitle = `${startStationZh?.name} - ${endStationZh?.name}`;
|
||||||
return (
|
return (
|
||||||
<div className="route-widget">
|
<div className="route-widget">
|
||||||
<div className={`route-widget-number ${getNumberSizeClass(route?.route_sys_number || context?.routeNumber)}`}>
|
<div className={`route-widget-number ${getNumberSizeClass(context?.routeNumber)}`}>
|
||||||
{route?.route_sys_number || context?.routeNumber || ""}
|
{context?.routeNumber || "No number"}
|
||||||
</div>
|
</div>
|
||||||
<div className="route-widget-content">
|
<div className="route-widget-content">
|
||||||
<div
|
<div
|
||||||
className={`route-widget-label ${
|
className={`route-widget-label ${
|
||||||
shouldAnimate(startStation?.name, 18) ? "marquee" : ""
|
shouldAnimate(startStation?.name, 18) ? "marquee" : ""
|
||||||
} ${getLabelSizeClass(startStation?.name)}`}
|
} ${getLabelSizeClass(startStation?.name)}`}
|
||||||
>
|
>
|
||||||
{startStation?.name}
|
{startStation?.name}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`route-widget-label ${
|
className={`route-widget-label ${
|
||||||
shouldAnimate(endStation?.name, 18) ? "marquee" : ""
|
shouldAnimate(endStation?.name, 18) ? "marquee" : ""
|
||||||
} ${getLabelSizeClass(endStation?.name)}`}
|
} ${getLabelSizeClass(endStation?.name)}`}
|
||||||
>
|
>
|
||||||
{endStation?.name}
|
{endStation?.name}
|
||||||
</div>
|
</div>
|
||||||
{(selectedLanguage === "en" || selectedLanguage === "ru") && (
|
{(selectedLanguage === "en" || selectedLanguage === "ru") && (
|
||||||
@@ -107,7 +107,7 @@ const RouteWidget = observer(() => {
|
|||||||
className={`route-widget-subtitle ${
|
className={`route-widget-subtitle ${
|
||||||
shouldAnimate(routeEnSubtitle, 50) ? "marquee" : ""
|
shouldAnimate(routeEnSubtitle, 50) ? "marquee" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{routeEnSubtitle}
|
{routeEnSubtitle}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -116,7 +116,7 @@ const RouteWidget = observer(() => {
|
|||||||
className={`route-widget-subtitle ${
|
className={`route-widget-subtitle ${
|
||||||
shouldAnimate(routeZhSubtitle, 50) ? "marquee" : ""
|
shouldAnimate(routeZhSubtitle, 50) ? "marquee" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{routeZhSubtitle}
|
{routeZhSubtitle}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -166,24 +166,15 @@
|
|||||||
animation: side-menu-marquee 14s linear infinite;
|
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 {
|
.side-menu-sight-transfer-list {
|
||||||
max-height: 0;
|
height: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
overflow: hidden;
|
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 {
|
.side-menu-sight-transfer-list.open {
|
||||||
max-height: 500px; /* Достаточно большое значение, чтобы вместить все пересадки */
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-of-sights {
|
.list-of-sights {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
-webkit-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
-webkit-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
||||||
-moz-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
-moz-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
||||||
box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
|
||||||
@@ -63,7 +65,7 @@
|
|||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
max-height: 68px;
|
max-height: 68px;
|
||||||
transition: max-height 0.15s ease;
|
transition: max-height 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,11 +107,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
width: 128px;
|
width: 128px;
|
||||||
|
|
||||||
background-color: color-mix(
|
background-color: var(--carrier-right-dark, #005a2f);
|
||||||
in srgb,
|
|
||||||
var(--carrier-right, #806c59) 80%,
|
|
||||||
black
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-of-sights-title {
|
.list-of-sights-title {
|
||||||
@@ -127,7 +125,7 @@
|
|||||||
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
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;
|
overscroll-behavior: contain;
|
||||||
touch-action: pan-y;
|
touch-action: pan-y;
|
||||||
@@ -146,7 +144,9 @@
|
|||||||
black calc(100% - var(--fade-bottom)),
|
black calc(100% - var(--fade-bottom)),
|
||||||
transparent 100%
|
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 {
|
.list-of-sights-content:not(.is-at-top) .scrollable {
|
||||||
@@ -171,6 +171,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-of-sights-content .custom-scrollbar-track {
|
.list-of-sights-content .custom-scrollbar-track {
|
||||||
|
margin-top: 14px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -247,18 +248,18 @@
|
|||||||
height: 1px;
|
height: 1px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: rgba(255, 255, 255, 0.3);
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
margin-bottom: 14px;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame {
|
.sight-frame {
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 66px;
|
bottom: 100px;
|
||||||
|
left: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 32px;
|
width: 100%;
|
||||||
width: 550px;
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
@@ -268,6 +269,12 @@
|
|||||||
),
|
),
|
||||||
var(--carrier-right, #806c59);
|
var(--carrier-right, #806c59);
|
||||||
max-height: calc(100vh - 128px);
|
max-height: calc(100vh - 128px);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sight-frame.three-d-fullscreen {
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-image {
|
.sight-frame-image {
|
||||||
@@ -284,6 +291,11 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sight-frame-content.is-switching {
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-get-back-wrapper {
|
.sight-frame-get-back-wrapper {
|
||||||
@@ -314,10 +326,22 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
color: white;
|
color: white;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
overflow-wrap: break-word;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fade-in-text {
|
||||||
|
animation: fadeInText 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInText {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sight-frame-title:not(.intro-title) {
|
.sight-frame-title:not(.intro-title) {
|
||||||
background:
|
background:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
@@ -345,18 +369,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-text-wrapper {
|
.sight-frame-text-wrapper {
|
||||||
flex-grow: 1;
|
|
||||||
padding: 16px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
max-height: calc(80vh - 354px);
|
max-height: calc(80vh - 354px);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 16px 16px 76px 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-text-wrapper.scrollable-container {
|
.sight-frame-text-wrapper.scrollable-container {
|
||||||
padding: 16px;
|
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 {
|
.sight-frame-text-wrapper .scrollable-viewport {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -367,11 +413,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-text {
|
.sight-frame-text {
|
||||||
|
display: flow-root; /* BFC prevents margin collapsing for correct scrollHeight measurement */
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: "Roboto";
|
font-family: "Roboto";
|
||||||
font-size: 18px;
|
font-size: 20px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 150%;
|
line-height: 150%;
|
||||||
@@ -386,7 +433,7 @@
|
|||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
transparent 35%,
|
transparent 35%,
|
||||||
color-mix(in srgb, var(--carrier-right, #806c59) 80%, black) 50%,
|
#0e8953 50%,
|
||||||
transparent 65%
|
transparent 65%
|
||||||
);
|
);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -409,8 +456,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-menu-wrapper {
|
.sight-frame-menu-wrapper {
|
||||||
position: relative;
|
position: absolute;
|
||||||
|
bottom: 100px;
|
||||||
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 60px;
|
||||||
|
z-index: 2;
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +472,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 120px;
|
width: 120px;
|
||||||
z-index: 3;
|
z-index: 100001;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 0.4s ease;
|
transition: opacity 0.4s ease;
|
||||||
}
|
}
|
||||||
@@ -445,7 +498,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-menu {
|
.sight-frame-menu {
|
||||||
z-index: 10000;
|
z-index: 100000;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 7px 60px;
|
padding: 7px 60px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -496,7 +549,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame-menu-point.active {
|
.sight-frame-menu-point.active {
|
||||||
font-weight: 600;
|
text-shadow: 0 0 0.4px #fff, 0 0 0.4px #fff;
|
||||||
border-bottom-color: #fff;
|
border-bottom-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,30 +575,20 @@
|
|||||||
}
|
}
|
||||||
.sight-frame {
|
.sight-frame {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px);
|
transition: opacity 0.9s ease-out;
|
||||||
transition:
|
|
||||||
opacity 0.2s ease-out,
|
|
||||||
transform 0.2s ease-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sight-frame.is-visible {
|
.sight-frame.is-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
animation: fadeInScale 0.9s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
animation: fadeInScale 0.6s ease-out forwards;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeInScale {
|
@keyframes fadeInScale {
|
||||||
0% {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px) scale(0.95);
|
|
||||||
}
|
}
|
||||||
50% {
|
to {
|
||||||
opacity: 0.7;
|
|
||||||
transform: translateY(-5px) scale(1.02);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,6 +640,7 @@
|
|||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 150%;
|
line-height: 150%;
|
||||||
|
padding-bottom: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.svg-container {
|
.svg-container {
|
||||||
@@ -654,15 +698,17 @@
|
|||||||
width: 40px;
|
width: 40px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
padding-top: 24px;
|
margin-top: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 20px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
height: 650px;
|
height: 676px;
|
||||||
padding-bottom: 30px;
|
|
||||||
touch-action: pan-y;
|
touch-action: pan-y;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
@@ -881,7 +927,7 @@
|
|||||||
border-radius: 32px;
|
border-radius: 32px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
background: var(--carrier-right, #806c59);
|
background: var(--carrier-main, #006f3a);
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,7 @@
|
|||||||
height: 96px;
|
height: 96px;
|
||||||
background-color: #fcd500;
|
background-color: #fcd500;
|
||||||
color: black;
|
color: black;
|
||||||
border-top-left-radius: 10px;
|
border-radius: 10px 0 0 10px;
|
||||||
border-bottom-left-radius: 10px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
|
|
||||||
.side-menu-buttons {
|
.side-menu-buttons {
|
||||||
width: 220px;
|
width: 220px;
|
||||||
margin-top: 40px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-button {
|
.side-menu-button {
|
||||||
@@ -198,6 +197,8 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
top: 250px;
|
top: 250px;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
border-radius: 10px 10px 0px 0px;
|
border-radius: 10px 10px 0px 0px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
@@ -213,8 +214,6 @@
|
|||||||
transition:
|
transition:
|
||||||
transform 0.3s ease-out,
|
transform 0.3s ease-out,
|
||||||
opacity 0.3s ease-out;
|
opacity 0.3s ease-out;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-sights.slide-in {
|
.side-menu-sights.slide-in {
|
||||||
@@ -253,10 +252,15 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-sight-selected-wrapper {
|
.side-menu-sight-wrapper {
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
margin-left: -20px;
|
margin-left: -20px;
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
|
background-color: transparent;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu-sight-selected-wrapper {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-sight > span {
|
.side-menu-sight > span {
|
||||||
|
|||||||
@@ -43,7 +43,8 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
transition: opacity 0.2s ease;
|
overflow: hidden;
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar-thumb {
|
.custom-scrollbar-thumb {
|
||||||
@@ -56,6 +57,7 @@
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
transition: opacity 0.5s ease, height 0.5s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar-thumb:active {
|
.custom-scrollbar-thumb:active {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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} />;
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,5 @@
|
|||||||
import { useRef, useEffect, useState } from "react";
|
import { useRef, useEffect, useState } from "react";
|
||||||
import { Widgets } from "./Widgets";
|
import { Widgets } from "./Widgets";
|
||||||
import { extend } from "@pixi/react";
|
|
||||||
import {
|
|
||||||
Container,
|
|
||||||
Graphics,
|
|
||||||
Sprite,
|
|
||||||
Texture,
|
|
||||||
TilingSprite,
|
|
||||||
Text,
|
|
||||||
} from "pixi.js";
|
|
||||||
import { Box, Stack } from "@mui/material";
|
import { Box, Stack } from "@mui/material";
|
||||||
import { MapDataProvider, useMapData } from "./MapDataContext";
|
import { MapDataProvider, useMapData } from "./MapDataContext";
|
||||||
import { TransformProvider, useTransform } from "./TransformContext";
|
import { TransformProvider, useTransform } from "./TransformContext";
|
||||||
@@ -22,15 +13,6 @@ import { UP_SCALE } from "./Constants";
|
|||||||
import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype";
|
import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype";
|
||||||
import { CircularProgress } from "@mui/material";
|
import { CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
extend({
|
|
||||||
Container,
|
|
||||||
Graphics,
|
|
||||||
Sprite,
|
|
||||||
Texture,
|
|
||||||
TilingSprite,
|
|
||||||
Text,
|
|
||||||
});
|
|
||||||
|
|
||||||
const Loading = () => {
|
const Loading = () => {
|
||||||
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
|
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
|
||||||
|
|
||||||
@@ -172,25 +154,6 @@ export const RouteMap = observer(() => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
|
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
|
||||||
{/* <Application resizeTo={parentRef} background="#000" preference="webgl">
|
|
||||||
<InfiniteCanvas>
|
|
||||||
<TravelPath points={points} />
|
|
||||||
{stationData[language].map((obj, index) => (
|
|
||||||
<Station
|
|
||||||
station={obj}
|
|
||||||
key={obj.id}
|
|
||||||
ruLabel={
|
|
||||||
language === "ru"
|
|
||||||
? stationData.ru[index].name
|
|
||||||
: stationData.ru[index].name
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{originalSightData?.map((sight: SightData, index: number) => {
|
|
||||||
return <Sight sight={sight} id={index} key={sight.id} />;
|
|
||||||
})}
|
|
||||||
</InfiniteCanvas>
|
|
||||||
</Application> */}
|
|
||||||
<WebGLRouteMapPrototype />
|
<WebGLRouteMapPrototype />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user