fix: Update map with tables fixes

This commit is contained in:
2025-07-09 18:56:18 +03:00
parent 78800ee2ae
commit e2547cb571
87 changed files with 5392 additions and 1410 deletions

View File

@ -1,4 +1,8 @@
import { FederatedMouseEvent, Graphics } from "pixi.js";
import { useCallback, useState, useEffect, useRef, FC } from "react";
import { observer } from "mobx-react-lite";
// --- Заглушки для зависимостей (замените на ваши реальные импорты) ---
import {
BACKGROUND_COLOR,
PATH_COLOR,
@ -7,140 +11,545 @@ import {
UP_SCALE,
} from "./Constants";
import { useTransform } from "./TransformContext";
import { useCallback, useState } from "react";
import { StationData } from "./types";
import { useMapData } from "./MapDataContext";
import { coordinatesToLocal } from "./utils";
import { observer } from "mobx-react-lite";
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 координаты.
*/
const getAnchorFromTextAlign = (align: TextAlign): { x: number; y: number } => {
const parts = align.split(" ");
const horizontal = parts[0] as HorizontalAlign;
const vertical = (parts[1] as VerticalAlign) || "center";
const horizontalMap: Record<HorizontalAlign, number> = {
left: 0,
center: 0.5,
right: 1,
};
const verticalMap: Record<VerticalAlign, number> = {
top: 0,
center: 0.5,
bottom: 1,
};
return { x: horizontalMap[horizontal], y: verticalMap[vertical] };
};
/**
* Получает координату 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;
}
export const Station = observer(
({ station, ruLabel }: Readonly<StationProps>) => {
const draw = useCallback((g: Graphics) => {
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 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 }}
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,
anchorPoint,
labelBlockAnchor: labelBlockAnchorProp,
labelAlign: labelAlignProp = "center",
onLabelAlignChange,
}: 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);
};
const handleControlPointerEnter = () => {
// Дополнительная обработка для панели управления
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setIsControlHovered(true);
setIsHovered(true);
};
const handleControlPointerLeave = () => {
setIsControlHovered(false);
// Если курсор не над основным контейнером, скрываем панель через некоторое время
if (!isHovered) {
hideTimer.current = setTimeout(() => {
setIsHovered(false);
}, 0);
}
};
const handlePointerLeave = () => {
// Увеличиваем время до скрытия панели и добавляем проверку
hideTimer.current = setTimeout(() => {
setIsHovered(false);
// Если курсор не над панелью управления, скрываем и её
if (!isControlHovered) {
setIsControlHovered(false);
}
}, 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;
const minDistance = 30;
const compensatedOffset = Math.max(minDistance / scale, 24 / scale);
const textBlockPosition = anchorPoint || position;
const finalLabelBlockAnchor = labelBlockAnchorProp || "right center";
const labelBlockAnchor =
typeof finalLabelBlockAnchor === "string"
? getAnchorFromTextAlign(finalLabelBlockAnchor)
: finalLabelBlockAnchor;
// Измеряем ширину верхнего лейбла
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 = (e.global.x - mouseStartPos.current.x) / scale;
const dy = (e.global.y - mouseStartPos.current.y) / scale;
const newPosition = {
x: dragStartPos.current.x + dx,
y: dragStartPos.current.y + dy,
};
// Проверяем, изменилась ли позиция
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 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}
eventMode="static"
interactive
cursor={isDragging ? "grabbing" : "grab"}
onPointerOver={handlePointerEnter}
onPointerOut={handlePointerLeave}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
onGlobalPointerMove={handlePointerMove}
>
<pixiContainer
position={{
x:
textBlockPosition.x +
compensatedOffset * (labelBlockAnchor.x === 1 ? -1 : 1),
y: textBlockPosition.y,
}}
anchor={labelBlockAnchor}
>
{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,
anchorPoint,
labelBlockAnchor,
labelAlign,
onLabelAlignChange,
}: Readonly<StationProps>) => {
const draw = useCallback(
(g: Graphics) => {
g.clear();
const coordinates = coordinatesToLocal(
station.latitude,
station.longitude
);
g.circle(
coordinates.x * UP_SCALE,
coordinates.y * UP_SCALE,
STATION_RADIUS
);
const radius = STATION_RADIUS;
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
g.fill({ color: PATH_COLOR });
g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH });
}, []);
},
[station.latitude, station.longitude]
);
return (
<pixiContainer>
<pixiGraphics draw={draw} />
<StationLabel station={station} ruLabel={ruLabel} />
</pixiContainer>
);
}
);
export const StationLabel = observer(
({ station, ruLabel }: Readonly<StationProps>) => {
const { rotation, scale } = useTransform();
const { setStationOffset } = useMapData();
const [position, setPosition] = useState({
x: station.offset_x,
y: station.offset_y,
});
const [isDragging, setIsDragging] = useState(false);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [startMousePosition, setStartMousePosition] = useState({
x: 0,
y: 0,
});
if (!station) {
console.error("station is null");
return null;
}
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsDragging(true);
setStartPosition({
x: position.x,
y: position.y,
});
setStartMousePosition({
x: e.globalX,
y: e.globalY,
});
e.stopPropagation();
};
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isDragging) return;
const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y;
const newPosition = {
x: startPosition.x + dx,
y: startPosition.y + dy,
};
setPosition(newPosition);
setStationOffset(station.id, newPosition.x, newPosition.y);
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => {
setIsDragging(false);
e.stopPropagation();
};
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
return (
<pixiContainer
eventMode="static"
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
width={48}
height={48}
x={coordinates.x * UP_SCALE}
y={coordinates.y * UP_SCALE}
rotation={-rotation}
>
<pixiText
anchor={{ x: 1, y: 0.5 }}
text={station.name}
position={{
x: position.x / scale + 24,
y: position.y / scale,
}}
style={{
fontSize: 26,
fontWeight: "bold",
fill: "#ffffff",
}}
/>
{ruLabel && (
<pixiText
anchor={{ x: 1, y: -1 }}
text={ruLabel}
position={{
x: position.x / scale + 24,
y: position.y / scale,
}}
style={{
fontSize: 16,
fontWeight: "bold",
fill: "#CCCCCC",
}}
/>
)}
</pixiContainer>
);
}
);
return (
<pixiContainer>
<pixiGraphics draw={draw} />
<StationLabel
station={station}
ruLabel={ruLabel}
anchorPoint={anchorPoint}
labelBlockAnchor={labelBlockAnchor}
labelAlign={labelAlign}
onLabelAlignChange={onLabelAlignChange}
/>
</pixiContainer>
);
};