+ {stationLabels.map((l, idx) => {
+ let station = stationData?.[idx];
+
+ const backendAlign = station?.align ?? 3;
+ const anchor = getAnchorFromOffset(backendAlign);
+ const transformCss = `translate(${-anchor.x * 100}%, ${
+ -anchor.y * 100
+ }%)`;
+
+ const fontSizePercent = routeData?.font_size ?? 100;
+ const fontScale = fontSizePercent / 100;
+
+ const clampedScale = Math.min(Math.max(scale, 1), 3);
+ const scaleFactor = 1 + (clampedScale - 1) * 0.4;
+
+ const primaryFontSize = 16 * fontScale * scaleFactor;
+ const secondaryFontSize = 13 * fontScale * scaleFactor;
+ const secondaryMarginTop = 5 * fontScale * scaleFactor;
+ const secondaryLineHeight = 1.2 * scaleFactor;
+
+ const alignment =
+ backendAlign === 1
+ ? "left"
+ : backendAlign === 3
+ ? "right"
+ : "center";
+
+ const secondaryPositionStyle =
+ alignment === "left"
+ ? { left: 0, transform: "none" }
+ : alignment === "right"
+ ? { right: 0, transform: "none" }
+ : { left: "50%", transform: "translateX(-50%)" };
+
+ const isMediaIdEmptyResult = isMediaIdEmpty(station?.icon);
+ const iconSrc = isMediaIdEmptyResult
+ ? null
+ : buildMediaDownloadUrl(mediaBaseUrl, station!.icon!, mediaToken);
+ const iconSizePx = Math.round(primaryFontSize * 1.2);
+
+ return (
+
+
+ {iconSrc ? (
+

+ ) : null}
+
+ {l.name}
+
+ {l.sub ? (
+
+ {l.sub}
+
+ ) : null}
+
+
+ );
+ })}
+ {clusteredSights.map((item) => {
+ const dpr = Math.max(
+ 1,
+ (typeof window !== "undefined" && window.devicePixelRatio) || 1,
+ );
+
+ if (item.type === "point") {
+ const s = item.data;
+ const rx = s.longitude;
+ const ry = s.latitude;
+ const isSelected = selectedSightId === String(s.id);
+ const hasIcon = !isMediaIdEmpty(s.icon ?? null);
+ const isCustomIcon = s.is_default_icon === false && hasIcon;
+ const hasSelectedAltIcon =
+ isSelected && isCustomIcon && !isMediaIdEmpty(s.alt_icon ?? null);
+ const defaultCustomIcon = isCustomIcon;
+ const shouldUseCustomSightIcon =
+ hasSelectedAltIcon || defaultCustomIcon;
+ const customSightIconScaleFactor = shouldUseCustomSightIcon
+ ? scale / Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6)
+ : 1;
+ const sightIconSizePercent = resolveSightIconSizePercent(s);
+ const sightIconUrl = hasSelectedAltIcon
+ ? buildMediaDownloadUrl(
+ mediaBaseUrl,
+ String(s.alt_icon),
+ mediaToken,
+ )
+ : defaultCustomIcon
+ ? buildMediaDownloadUrl(
+ mediaBaseUrl,
+ String(s.icon),
+ mediaToken,
+ )
+ : SIGHT_ICON_URL;
+ const iconSize =
+ SIGHT_ICON_BASE_SIZE *
+ clamp(sightIconSizePercent / 100, 0.1, 10) *
+ customSightIconScaleFactor;
+
+ const screenX = (rx * scale + position.x) / dpr;
+ const screenY = (ry * scale + position.y) / dpr;
+ const iconLeft = screenX - iconSize;
+ const iconTop = screenY - iconSize;
+
+ const handleSightClick = () => {
+ setIsGovernorWidgetOpen(false);
+ setSelectedSightId(String(s.id));
+ setIsManualSelection(true);
+ setIsRightWidgetSelectorOpen(false);
+ store.closeGovernorModal();
+ };
+
+ const handleSightTouch = (e: React.TouchEvent) => {
+ e.stopPropagation();
+ handleSightClick();
+ };
+
+ return (
+
+
+

+
+
+ );
+ } else {
+ const cluster = item.data;
+ const rx = cluster.longitude;
+ const ry = cluster.latitude;
+
+ const screenX = (rx * scale + position.x) / dpr;
+ const screenY = (ry * scale + position.y) / dpr;
+
+ const isExpanded = activeClusterId === cluster.id;
+ const selectedSightInCluster = cluster.sights.find(
+ (sight) => selectedSightId === String(sight.id),
+ );
+ const selectedHasIconInCluster =
+ selectedSightInCluster != null &&
+ !isMediaIdEmpty(selectedSightInCluster.icon ?? null);
+ const selectedIsCustomInCluster =
+ selectedSightInCluster?.is_default_icon === false &&
+ selectedHasIconInCluster;
+
+ const iconSizePercent = resolveSightIconSizePercent(
+ cluster.sights[0],
+ );
+ const clusterCustomScaleFactor = selectedIsCustomInCluster
+ ? scale / Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6)
+ : 1;
+ const iconSize =
+ SIGHT_ICON_BASE_SIZE *
+ clamp(iconSizePercent / 100, 0.1, 10) *
+ clusterCustomScaleFactor;
+
+ const iconLeft = screenX - iconSize;
+ const iconTop = screenY - iconSize;
+ const hasSelectedAltIconInCluster =
+ selectedSightInCluster != null &&
+ selectedIsCustomInCluster &&
+ !isMediaIdEmpty(selectedSightInCluster.alt_icon ?? null);
+ const clusterIconUrl = hasSelectedAltIconInCluster
+ ? buildMediaDownloadUrl(
+ mediaBaseUrl,
+ String(selectedSightInCluster?.alt_icon),
+ mediaToken,
+ )
+ : selectedIsCustomInCluster
+ ? buildMediaDownloadUrl(
+ mediaBaseUrl,
+ String(selectedSightInCluster?.icon),
+ mediaToken,
+ )
+ : SIGHT_ICON_URL;
+
+ const handleClusterClick = (
+ e: React.MouseEvent | React.TouchEvent,
+ ) => {
+ e.stopPropagation();
+ if (isExpanded) {
+ setActiveClusterId(null);
+ } else {
+ setActiveClusterId(cluster.id);
+ }
+ };
+
+ const handleCircleInteraction = () => {
+ if (isExpanded && clusterAutoCloseTimerRef.current) {
+ clearTimeout(clusterAutoCloseTimerRef.current);
+ clusterAutoCloseTimerRef.current = setTimeout(() => {
+ setActiveClusterId(null);
+ }, 20000);
+ }
+ };
+
+ const handleSightInClusterClick = (
+ sightId: string,
+ e: React.MouseEvent | React.TouchEvent,
+ ) => {
+ e.stopPropagation();
+ setIsGovernorWidgetOpen(false);
+ setSelectedSightId(sightId);
+ setIsManualSelection(true);
+ setIsRightWidgetSelectorOpen(false);
+ store.closeGovernorModal();
+ setActiveClusterId(null);
+ };
+
+ const hasSelectedSight = selectedSightId
+ ? cluster.sights.some(
+ (sight) => String(sight.id) === selectedSightId,
+ )
+ : false;
+
+ const badgeColor = "var(--carrier-main, #006F3A)";
+ const listPanelWidth = 200;
+ const listItemHeight = 30;
+ const listMaxHeight = 250;
+ const hasMoreThanTwo = cluster.sights.length > 2;
+ const shouldShowScrollbar = hasMoreThanTwo;
+
+ return (
+
+ {!isExpanded ? (
+
+
+

+
+ {cluster.count}
+
+
+
+ ) : (
+
+
+

+
+ {cluster.count}
+
+
+
+
e.stopPropagation()}
+ >
+
+ {cluster.sights.map((sight, index) => {
+ const isSelected =
+ selectedSightId === String(sight.id);
+ const getSightName = () => {
+ const originalSight =
+ selectedLanguage === "ru"
+ ? routeSights?.find((s) => s.id === sight.id)
+ : selectedLanguage === "en"
+ ? routeSightsEn?.find(
+ (s) => s.id === sight.id,
+ )
+ : routeSightsZh?.find(
+ (s) => s.id === sight.id,
+ );
+
+ if (originalSight?.short_name) {
+ return originalSight.short_name;
+ }
+
+ if (!originalSight?.left_article) {
+ return (
+ sight.name ||
+ `Достопримечательность ${index + 1}`
+ );
+ }
+
+ const leftArticleData =
+ selectedLanguage === "ru"
+ ? sightArticles.get(
+ originalSight.left_article + "_ru",
+ )
+ : selectedLanguage === "en"
+ ? sightArticlesEn.get(
+ originalSight.left_article + "_en",
+ )
+ : sightArticlesZh.get(
+ originalSight.left_article + "_zh",
+ );
+
+ return (
+ leftArticleData?.heading ||
+ sight.name ||
+ `Достопримечательность ${index + 1}`
+ );
+ };
+
+ const sightName = getSightName();
+
+ return (
+
+ handleSightInClusterClick(String(sight.id), e)
+ }
+ onTouchEnd={(e) =>
+ handleSightInClusterClick(String(sight.id), e)
+ }
+ style={{
+ display: "flex",
+ alignItems: "center",
+ height: `${listItemHeight}px`,
+ cursor: "pointer",
+ userSelect: "none",
+ touchAction: "none",
+ padding: "0 4px",
+ zIndex: 1000000000000,
+ borderBottom:
+ "1px solid rgba(255, 255, 255, 0.1)",
+ transition: "background-color 0.2s",
+ }}
+ >
+ {(() => {
+ const hasRowIcon = !isMediaIdEmpty(
+ sight.icon ?? null,
+ );
+ const isRowCustomIcon =
+ sight.is_default_icon === false && hasRowIcon;
+ const hasRowAltIcon =
+ isSelected &&
+ isRowCustomIcon &&
+ !isMediaIdEmpty(sight.alt_icon ?? null);
+ const rowIconUrl = hasRowAltIcon
+ ? buildMediaDownloadUrl(
+ mediaBaseUrl,
+ String(sight.alt_icon),
+ mediaToken,
+ )
+ : isRowCustomIcon
+ ? buildMediaDownloadUrl(
+ mediaBaseUrl,
+ String(sight.icon),
+ mediaToken,
+ )
+ : SIGHT_ICON_URL;
+ return (
+

+ );
+ })()}
+
+ {sightName}
+
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+ }
+ })}
+
+ {(() => {
+ if (!routeData) return null;
+ const centerLat = routeData.center_latitude;
+ const centerLon = routeData.center_longitude;
+ if (centerLat === undefined || centerLon === undefined) return null;
+
+ if (!apiStore?.context?.currentCoordinates) return null;
+
+ const rx = animatedYellowDotPosition.x;
+ const ry = animatedYellowDotPosition.y;
+ if (rx === 0 && ry === 0) return null;
+
+ const dpr2 = Math.max(
+ 1,
+ (typeof window !== "undefined" && window.devicePixelRatio) || 1,
+ );
+ const cosR = Math.cos(rotationAngle);
+ const sinR = Math.sin(rotationAngle);
+ const pathPts: { x: number; y: number }[] = [];
+ for (let i = 0; i < routePath.length; i += 2)
+ pathPts.push({ x: routePath[i], y: routePath[i + 1] });
+ const stationsForAngle = (stationData || []).map((st: any) => {
+ const loc = coordinatesToLocal(
+ st.latitude - centerLat,
+ st.longitude - centerLon,
+ );
+ const x = loc.x * UP_SCALE,
+ y = loc.y * UP_SCALE;
+ const rx2 = x * cosR - y * sinR,
+ ry2 = x * sinR + y * cosR;
+ return {
+ longitude: rx2,
+ latitude: ry2,
+ offset_x: st.offset_x,
+ offset_y: st.offset_y,
+ align: st.align,
+ };
+ });
+ let tramSegIndex = getCurrentSegIndex();
+ if (tramSegIndex < 0 && routePath.length >= 4) {
+ let best = -1,
+ bestD = Infinity;
+ for (let i = 0; i < routePath.length - 2; i += 2) {
+ const p1x = routePath[i],
+ p1y = routePath[i + 1];
+ const p2x = routePath[i + 2],
+ p2y = routePath[i + 3];
+ const dx = p2x - p1x,
+ dy = p2y - p1y;
+ const len2 = dx * dx + dy * dy;
+ if (!len2) continue;
+ const t = ((rx - p1x) * dx + (ry - p1y) * dy) / len2;
+ const cl = Math.max(0, Math.min(1, t));
+ const px = p1x + cl * dx,
+ py = p1y + cl * dy;
+ const d = Math.hypot(rx - px, ry - py);
+ if (d < bestD) {
+ bestD = d;
+ best = i / 2;
+ }
+ }
+ tramSegIndex = best;
+ }
+ const optimalAngle = (() => {
+ const testRadiusInMap = 100 / scale;
+ const minPath = 60,
+ minPassed = 60,
+ minStation = 100;
+ let bestAng = 0,
+ bestScore = Infinity;
+ for (let i = 0; i < 12; i++) {
+ const ang = (i * Math.PI * 2) / 12;
+ const tx = rx + Math.cos(ang) * testRadiusInMap;
+ const ty = ry + Math.sin(ang) * testRadiusInMap;
+ const distPath = (function () {
+ if (pathPts.length < 2) return Infinity;
+ let md = Infinity;
+ for (let k = 0; k < pathPts.length - 1; k++) {
+ const p1 = pathPts[k],
+ p2 = pathPts[k + 1];
+ const L2 = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
+ if (!L2) continue;
+ const tt =
+ ((tx - p1.x) * (p2.x - p1.x) +
+ (ty - p1.y) * (p2.y - p1.y)) /
+ L2;
+ const cl = Math.max(0, Math.min(1, tt));
+ const px = p1.x + cl * (p2.x - p1.x),
+ py = p1.y + cl * (p2.y - p1.y);
+ const d = Math.hypot(tx - px, ty - py);
+ if (d < md) md = d;
+ }
+ return md * scale;
+ })();
+ const distPassed = (function () {
+ if (pathPts.length < 2 || tramSegIndex < 0) return Infinity;
+ let md = Infinity;
+ for (
+ let k = 0;
+ k <= Math.min(tramSegIndex, pathPts.length - 2);
+ k++
+ ) {
+ const p1 = pathPts[k],
+ p2 = pathPts[k + 1];
+ const L2 = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
+ if (!L2) continue;
+ const tt =
+ ((tx - p1.x) * (p2.x - p1.x) +
+ (ty - p1.y) * (p2.y - p1.y)) /
+ L2;
+ const cl = Math.max(0, Math.min(1, tt));
+ const px = p1.x + cl * (p2.x - p1.x),
+ py = p1.y + cl * (p2.y - p1.y);
+ const d = Math.hypot(tx - px, ty - py);
+ if (d < md) md = d;
+ }
+ return md * scale;
+ })();
+ const distStation = (function () {
+ if (!stationsForAngle.length) return Infinity;
+ let md = Infinity;
+
+ const LABEL_WIDTH_PX = 120;
+
+ const labelWidthMap = LABEL_WIDTH_PX / scale;
+
+ for (const st of stationsForAngle) {
+ const align = st.align ?? 3;
+
+ let lx = st.longitude;
+ let ly = st.latitude;
+
+ if (align === 1 || align === 0) {
+ lx += labelWidthMap / 2;
+ } else if (align === 3) {
+ lx -= labelWidthMap / 2;
+ }
+
+ const d = Math.hypot(tx - lx, ty - ly);
+ if (d < md) md = d;
+ }
+ return md * scale;
+ })();
+ let weight = 0;
+ if (distPath < minPath) weight += 100 * (1 - distPath / minPath);
+ if (distPassed < minPassed)
+ weight += 10 * (1 - distPassed / minPassed);
+ if (distStation < minStation)
+ weight += 1000 * (1 - distStation / minStation);
+ if (weight < bestScore) {
+ bestScore = weight;
+ bestAng = ang;
+ }
+ }
+ return bestAng;
+ })();
+
+ const offsetBack = 5 / scale;
+ const adjustedRx = rx - Math.cos(optimalAngle) * offsetBack;
+ const adjustedRy = ry - Math.sin(optimalAngle) * offsetBack;
+ const screenX = (adjustedRx * scale + position.x) / dpr2;
+ const screenY = (adjustedRy * scale + position.y) / dpr2;
+
+ const screenXDot = (rx * scale + position.x) / dpr2;
+ const screenYDot = (ry * scale + position.y) / dpr2;
+
+ let activeStationIndex = -1;
+ const tramCoords = apiStore?.context?.currentCoordinates;
+
+ if (
+ tramCoords &&
+ stationData &&
+ stationData.length > 0 &&
+ Number.isFinite(tramCoords.latitude) &&
+ Number.isFinite(tramCoords.longitude)
+ ) {
+ let bestD = Infinity;
+ const DISTANCE_THRESHOLD = 0.00015;
+
+ for (let i = 0; i < stationData.length; i++) {
+ const station = stationData[i];
+ if (
+ Number.isFinite(station.latitude) &&
+ Number.isFinite(station.longitude)
+ ) {
+ const d = getDistance(
+ {
+ latitude: tramCoords.latitude,
+ longitude: tramCoords.longitude,
+ },
+ {
+ latitude: station.latitude,
+ longitude: station.longitude,
+ },
+ );
+ if (d < bestD) {
+ bestD = d;
+ activeStationIndex = i;
+ }
+ }
+ }
+
+ if (bestD > DISTANCE_THRESHOLD) {
+ activeStationIndex = -1;
+ }
+ }
+
+ const isAtStation = activeStationIndex !== -1;
+
+ const dotColor = isAtStation ? "#fcd500" : "white";
+
+ return (
+ <>
+
+
+ >
+ );
+ })()}
+