Files
WhiteNightsAdminPanel/src/client/src/components/map/Sight.tsx
2026-04-24 16:44:18 +03:00

394 lines
11 KiB
TypeScript

// 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}
/>
);
})}
</>
);
}