394 lines
11 KiB
TypeScript
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}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|