Files
WhiteNightsAdminPanel/src/pages/Route/route-preview/Station.tsx
fisenko 03fd04a420 #18 Корректировки 01.11.25 (#19)
Reviewed-on: #19
Reviewed-by: Микаэл Оганесян <15lu.akari@unprism.ru>
Co-authored-by: fisenko <kkzemeow@gmail.com>
Co-committed-by: fisenko <kkzemeow@gmail.com>
2025-11-07 07:16:29 +00:00

540 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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;
}
};
const getSecondLabelAnchor = (): number => {
switch (currentLabelAlign) {
case "left":
return 0;
case "center":
return 0.5;
case "right":
return 1;
default:
return 0.5;
}
};
return (
<pixiContainer
x={coordinates.x * UP_SCALE}
y={coordinates.y * UP_SCALE}
rotation={-rotation}
zIndex={isHovered || isControlHovered ? 1000 : 0}
eventMode="static"
interactive
cursor={isDragging ? "grabbing" : "grab"}
onPointerOver={handlePointerEnter}
onPointerOut={handlePointerLeave}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
onGlobalPointerMove={handlePointerMove}
>
<pixiContainer
position={{
x:
(position.x + Math.cos(Math.atan2(position.y, position.x))) /
scale,
y:
(position.y + Math.sin(Math.atan2(position.y, position.x))) /
scale,
}}
anchor={dynamicAnchor}
zIndex={isHovered || isControlHovered ? 1000 : 0}
>
{ruLabel && (
<pixiText
ref={ruLabelRef}
text={ruLabel}
position={{ x: 0, y: 0 }}
anchor={{ x: 0.5, y: 0.5 }}
style={{
fontSize: compensatedRuFontSize,
fontWeight: "bold",
fill: "#ffffff",
}}
/>
)}
{station.name && language !== "ru" && ruLabel && (
<pixiText
text={station.name}
position={{
x: getSecondLabelPosition(),
y: compensatedRuFontSize * 1.1,
}}
anchor={{ x: getSecondLabelAnchor(), y: 0.5 }}
style={{
fontSize: compensatedNameFontSize,
fontWeight: "bold",
fill: "#CCCCCC",
}}
/>
)}
{(isHovered || isControlHovered) && !isDragging && (
<LabelAlignmentControl
scale={scale}
currentAlign={currentLabelAlign}
onAlignChange={handleAlignChange}
onPointerOver={handlePointerEnter}
onPointerOut={handlePointerLeave}
onControlPointerEnter={handleControlPointerEnter}
onControlPointerLeave={handleControlPointerLeave}
/>
)}
</pixiContainer>
</pixiContainer>
);
}
);
export const Station = ({
station,
ruLabel,
labelAlign,
onLabelAlignChange,
}: Readonly<StationProps>) => {
const [isTextHovered, setIsTextHovered] = useState(false);
const draw = useCallback(
(g: Graphics) => {
g.clear();
const coordinates = coordinatesToLocal(
station.latitude,
station.longitude
);
const radius = STATION_RADIUS;
const strokeWidth = STATION_OUTLINE_WIDTH;
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
if (isTextHovered) {
g.fill({ color: 0x00aaff });
g.stroke({ color: 0xffffff, width: strokeWidth + 1 });
} else {
g.fill({ color: PATH_COLOR });
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });
}
},
[station.latitude, station.longitude, isTextHovered]
);
return (
<pixiContainer zIndex={isTextHovered ? 1000 : 0}>
<pixiGraphics draw={draw} />
<StationLabel
station={station}
ruLabel={ruLabel}
labelAlign={labelAlign}
onLabelAlignChange={onLabelAlignChange}
onTextHover={setIsTextHovered}
/>
</pixiContainer>
);
};