576 lines
18 KiB
TypeScript
576 lines
18 KiB
TypeScript
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<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); // 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 (
|
||
<pixiContainer
|
||
x={coordinates.x * UP_SCALE}
|
||
y={coordinates.y * UP_SCALE}
|
||
rotation={-rotation}
|
||
zIndex={isHovered || isControlHovered ? 1000 : 0}
|
||
eventMode="static"
|
||
interactive
|
||
cursor={isDragging ? "grabbing" : "grab"}
|
||
onPointerOver={handlePointerEnter}
|
||
onPointerOut={handlePointerLeave}
|
||
onPointerDown={handlePointerDown}
|
||
onPointerUp={handlePointerUp}
|
||
onPointerUpOutside={handlePointerUp}
|
||
onGlobalPointerMove={handlePointerMove}
|
||
>
|
||
<pixiContainer
|
||
position={{
|
||
x:
|
||
(position.x + Math.cos(Math.atan2(position.y, position.x))) /
|
||
scale,
|
||
y:
|
||
(position.y + Math.sin(Math.atan2(position.y, position.x))) /
|
||
scale,
|
||
}}
|
||
anchor={dynamicAnchor}
|
||
zIndex={isHovered || isControlHovered ? 1000 : 0}
|
||
>
|
||
{ruLabel && (
|
||
<pixiText
|
||
ref={ruLabelRef}
|
||
text={ruLabel}
|
||
position={{ x: 0, y: 0 }}
|
||
anchor={{ x: 0.5, y: 0.5 }}
|
||
style={{
|
||
fontSize: compensatedRuFontSize,
|
||
fontWeight: "bold",
|
||
fill: "#ffffff",
|
||
}}
|
||
/>
|
||
)}
|
||
{station.name && language !== "ru" && ruLabel && (
|
||
<pixiText
|
||
text={station.name}
|
||
position={{
|
||
x: getSecondLabelPosition(),
|
||
y: compensatedRuFontSize * 1.1,
|
||
}}
|
||
anchor={{ x: getSecondLabelAnchor(), y: 0.5 }}
|
||
style={{
|
||
fontSize: compensatedNameFontSize,
|
||
fontWeight: "bold",
|
||
fill: "#CCCCCC",
|
||
}}
|
||
/>
|
||
)}
|
||
{(isHovered || isControlHovered) && !isDragging && (
|
||
<LabelAlignmentControl
|
||
scale={scale}
|
||
currentAlign={currentLabelAlign}
|
||
onAlignChange={handleAlignChange}
|
||
onPointerOver={handlePointerEnter}
|
||
onPointerOut={handlePointerLeave}
|
||
onControlPointerEnter={handleControlPointerEnter}
|
||
onControlPointerLeave={handleControlPointerLeave}
|
||
/>
|
||
)}
|
||
</pixiContainer>
|
||
</pixiContainer>
|
||
);
|
||
}
|
||
);
|
||
|
||
// =========================================================================
|
||
// Главный экспортируемый компонент: Станция
|
||
// =========================================================================
|
||
|
||
export const Station = ({
|
||
station,
|
||
ruLabel,
|
||
|
||
labelAlign,
|
||
onLabelAlignChange,
|
||
}: Readonly<StationProps>) => {
|
||
const [isTextHovered, setIsTextHovered] = useState(false);
|
||
|
||
const draw = useCallback(
|
||
(g: Graphics) => {
|
||
g.clear();
|
||
const coordinates = coordinatesToLocal(
|
||
station.latitude,
|
||
station.longitude
|
||
);
|
||
|
||
const radius = STATION_RADIUS;
|
||
const strokeWidth = STATION_OUTLINE_WIDTH;
|
||
|
||
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
|
||
|
||
// 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 (
|
||
<pixiContainer zIndex={isTextHovered ? 1000 : 0}>
|
||
<pixiGraphics draw={draw} />
|
||
<StationLabel
|
||
station={station}
|
||
ruLabel={ruLabel}
|
||
labelAlign={labelAlign}
|
||
onLabelAlignChange={onLabelAlignChange}
|
||
onTextHover={setIsTextHovered}
|
||
/>
|
||
</pixiContainer>
|
||
);
|
||
};
|