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"; // --- Конец заглушек --- // --- Декларации для react-pixi --- // (В реальном проекте типы должны быть предоставлены библиотекой @pixi/react-pixi) declare const pixiContainer: any; declare const pixiGraphics: any; declare const pixiText: any; // --- Типы --- type HorizontalAlign = "left" | "center" | "right"; type VerticalAlign = "top" | "center" | "bottom"; type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`; type LabelAlign = "left" | "center" | "right"; // --- Утилиты --- /** * Преобразует текстовое позиционирование в anchor координаты. */ /** * Получает координату 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 {} 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 = ({ 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 ( { e.stopPropagation(); onControlPointerEnter(); }} onPointerOut={(e: FederatedMouseEvent) => { e.stopPropagation(); onControlPointerLeave(); }} onPointerDown={(e: FederatedMouseEvent) => { e.stopPropagation(); }} > {/* Основной фон */} {/* Кнопки с подсветкой */} {alignOptions.map((option, index) => ( {/* Подсветка активной кнопки */} drawButtonHighlight(g, index, option.key === currentAlign) } /> {/* Текст кнопки */} { e.stopPropagation(); onAlignChange(option.key); }} onPointerDown={(e: FederatedMouseEvent) => { e.stopPropagation(); onAlignChange(option.key); }} onPointerOver={(e: FederatedMouseEvent) => { e.stopPropagation(); onControlPointerEnter(); }} /> ))} ); }; // ========================================================================= // Компонент: Метка Станции (с логикой) // ========================================================================= const StationLabel = observer( ({ station, ruLabel, labelAlign: labelAlignProp = "center", onLabelAlignChange, onTextHover, }: Readonly) => { 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(null); const ruLabelRef = useRef(null); useEffect(() => { return () => { if (hideTimer.current) { clearTimeout(hideTimer.current); } }; }, []); const handlePointerEnter = () => { if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null; } setIsHovered(true); onTextHover?.(true); // Call the callback to indicate text is hovered }; const handleControlPointerEnter = () => { // Дополнительная обработка для панели управления if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null; } setIsControlHovered(true); setIsHovered(true); onTextHover?.(true); // Call the callback to indicate text/control is hovered }; const handleControlPointerLeave = () => { setIsControlHovered(false); // Если курсор не над основным контейнером, скрываем панель через некоторое время if (!isHovered) { hideTimer.current = setTimeout(() => { setIsHovered(false); onTextHover?.(false); // Call the callback to indicate neither text nor control is hovered }, 0); } }; const handlePointerLeave = () => { // Увеличиваем время до скрытия панели и добавляем проверку hideTimer.current = setTimeout(() => { setIsHovered(false); // Если курсор не над панелью управления, скрываем и её if (!isControlHovered) { setIsControlHovered(false); } onTextHover?.(false); // Call the callback to indicate text is no longer hovered }, 100); // Увеличиваем время до скрытия панели }; useEffect(() => { setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 }); }, [station.offset_x, station.offset_y, station.id]); // Функция для конвертации числового align в строковый const convertNumericAlign = (align: number): LabelAlign => { switch (align) { case 0: return "left"; case 1: return "center"; case 2: return "right"; default: return "center"; } }; // Функция для конвертации строкового align в числовой const convertStringAlign = (align: LabelAlign): number => { switch (align) { case "left": return 0; case "center": return 1; case "right": return 2; default: return 1; } }; useEffect(() => { setCurrentLabelAlign(convertNumericAlign(station.align ?? 1)); }, [station.align]); if (!station) return null; const coordinates = coordinatesToLocal(station.latitude, station.longitude); const compensatedRuFontSize = (26 * 0.75) / scale; const compensatedNameFontSize = (16 * 0.75) / scale; // Измеряем ширину верхнего лейбла 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; } }; // Функция для расчета anchor нижнего лейбла const getSecondLabelAnchor = (): number => { switch (currentLabelAlign) { case "left": return 0; // anchor.x = 0 (левый край) case "center": return 0.5; // anchor.x = 0.5 (центр) case "right": return 1; // anchor.x = 1 (правый край) default: return 0.5; } }; return ( {ruLabel && ( )} {station.name && language !== "ru" && ruLabel && ( )} {(isHovered || isControlHovered) && !isDragging && ( )} ); } ); // ========================================================================= // Главный экспортируемый компонент: Станция // ========================================================================= export const Station = ({ station, ruLabel, labelAlign, onLabelAlignChange, }: Readonly) => { 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); // Change fill color when text is hovered if (isTextHovered) { g.fill({ color: 0x00aaff }); // Highlight color when hovered g.stroke({ color: 0xffffff, width: strokeWidth + 1 }); // Brighter outline when hovered } else { g.fill({ color: PATH_COLOR }); g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth }); } }, [station.latitude, station.longitude, isTextHovered] ); return ( ); };