simple-wind-indicator
Version:
Universal wind direction and speed indicator component for React
914 lines (810 loc) • 39.2 kB
JSX
import React from "react";
import { Box, Paper, Typography } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import NavigationIcon from "@mui/icons-material/Navigation";
import { arc } from "d3-shape";
//1.0.4-a
/**
* SimpleWindIndicator - Universal wind direction and speed indicator component
*
* @param {Object} props - Component props
* @param {number} props.direction - Wind direction in degrees (0-360)
* @param {number} props.speed - Wind speed value
* @param {number} [props.minDirection] - Minimum direction for blue segment (degrees)
* @param {number} [props.maxDirection] - Maximum direction for blue segment (degrees)
* @param {boolean} [props.autoExpandSegment=true] - If true, segment automatically expands to include arrow; if false, segment stays fixed
* @param {boolean} [props.enableRawArc=false] - When autoExpandSegment=false, if true, always draws full arc between min/max without choosing shorter path
* @param {number} [props.maxSpeed=32] - Maximum speed value for arrow length calculation
* @param {number} [props.minArrowLength=0] - Minimum arrow length as percentage (0-1)
* @param {number} [props.maxArrowLength=1.35] - Maximum arrow length as percentage (0-2)
* @param {string} [props.arrowColor] - Custom arrow color (hex/rgb/theme color)
* @param {string} [props.segmentColor] - Custom segment color (hex/rgb/theme color)
* @param {string} [props.subtitleColor] - Custom subtitle text color (hex/rgb/theme color)
* @param {string} [props.title] - Main title text
* @param {string} [props.subtitle] - Subtitle text (replaces avgMode)
* @param {string} [props.titleColor] - Title text color
* @param {string|number} [props.titleSize] - Title font size (CSS value)
* @param {string} [props.titleWeight='normal'] - Title font weight (normal/bold/100-900)
* @param {string} [props.subtitleSize] - Subtitle font size (CSS value)
* @param {string} [props.subtitleWeight='bold'] - Subtitle font weight
* @param {string} [props.valuesColor] - Speed/direction values text color
* @param {string|number} [props.valuesSize] - Values font size (CSS value)
* @param {string} [props.valuesWeight='normal'] - Values font weight
* @param {string} [props.compassColor] - Compass directions (N,S,E,W) text color
* @param {string} [props.backgroundColor] - Background color of the circle
* @param {string} [props.borderColor] - Border color of the circle
* @param {string|number} [props.borderWidth=3] - Border width in pixels
* @param {string} [props.borderStyle='solid'] - Border style (solid, dashed, dotted)
* @param {string|number} [props.arrowThickness=2] - Arrow tail thickness in pixels
* @param {string|number} [props.arrowHeadSize] - Arrow head size multiplier
* @param {number} [props.arrowOpacity=1] - Arrow opacity (0-1)
* @param {string} [props.arrowShadow] - Arrow shadow CSS value
* @param {number} [props.segmentOpacity=0.6] - Segment opacity (0-1)
* @param {string|number} [props.segmentThickness] - Segment thickness in pixels
* @param {string|number} [props.compassSize] - Compass labels font size
* @param {string} [props.compassWeight='bold'] - Compass labels font weight
* @param {number} [props.compassOpacity=0.5] - Compass labels opacity (0-1)
* @param {Array} [props.customCompassLabels] - Custom compass labels [N, E, S, W]
* @param {string|number} [props.animationDuration='1.0s'] - Animation duration
* @param {string} [props.animationType='ease-out'] - Animation timing function
* @param {boolean} [props.enableArrowAnimation=true] - Enable arrow direction and length animation
* @param {boolean} [props.enableSegmentAnimation=true] - Enable segment boundaries animation
* @param {boolean} [props.enableTextAnimation=true] - Enable speed and direction text animation
* @param {boolean} [props.enableHoverEffects=false] - Enable hover effects
* @param {Function} [props.onClick] - Click handler function
* @param {Function} [props.onHover] - Hover handler function
* @param {string} [props.tooltip] - Tooltip text
* @param {boolean} [props.hideNullValues=false] - Hide null values instead of showing "--"
* @param {string} [props.preset] - Predefined theme preset (dark, light, ocean, sunset)
* @param {string} [props.variant='standard'] - Display variant (standard, compact, detailed)
* @param {string} [props.speedUnit='m/s'] - Primary speed unit
* @param {string} [props.secondarySpeedUnit='km/h'] - Secondary speed unit
* @param {number} [props.speedUnitMultiplier=3.6] - Multiplier for secondary unit
* @param {boolean} [props.showCompass=true] - Show compass directions (N,S,E,W)
* @param {boolean} [props.showSegment=true] - Show blue direction segment
* @param {boolean} [props.showSpeed=true] - Show speed values
* @param {boolean} [props.showDirection=true] - Show direction degrees
* @param {boolean} [props.showTitle=true] - Show title below component
* @param {boolean} [props.showSubtitle=true] - Show subtitle inside component
* @param {number} [props.circleSize=150] - Component size in pixels
* @param {Object} [props.customStyles] - Custom styling overrides
*/
const SimpleWindIndicator = ({
// Core data
direction,
speed,
// Segment configuration
minDirection,
maxDirection,
autoExpandSegment = true,
enableRawArc = false,
// Arrow configuration
maxSpeed = 32,
minArrowLength = 0,
maxArrowLength = 1.35,
arrowColor,
// Visual customization
segmentColor,
subtitleColor,
title,
subtitle,
titleColor,
titleSize = 16,
titleWeight = 'normal',
subtitleSize= 13,
subtitleWeight = 'bold',
valuesColor,
valuesSize= 11.5,
valuesWeight = 'normal',
compassColor,
backgroundColor,
// Border styling
borderColor,
borderWidth = 3,
borderStyle = 'solid',
// Arrow styling
arrowThickness = 2,
arrowHeadSize,
arrowOpacity = 1,
arrowShadow,
// Segment styling
segmentOpacity = 0.8,
segmentThickness,
// Compass styling
compassSize,
compassWeight = 'bold',
compassOpacity = 0.5,
customCompassLabels,
// Animation and interaction
animationDuration = '1.0s',
animationType = 'ease-out',
enableArrowAnimation = true,
enableSegmentAnimation = true,
enableTextAnimation = true,
enableHoverEffects = false,
onClick,
onHover,
tooltip,
// Data display
hideNullValues = false,
// Themes and variants
preset,
variant = 'standard',
// Units and display
speedUnit = 'm/s',
secondarySpeedUnit = 'km/h',
speedUnitMultiplier = 3.6,
// Visibility controls
showCompass = true,
showSegment = true,
showSpeed = true,
showDirection = true,
showTitle = true,
showSubtitle = true,
// Size and styling
circleSize = 150,
customStyles = {}
}) => {
const theme = useTheme();
// Apply theme presets
const applyThemePresets = () => {
const presets = {
dark: {
backgroundColor: '#2d2d2d',
titleColor: '#ffffff',
subtitleColor: '#00bcd4',
valuesColor: '#ffffff',
compassColor: '#ffffff',
arrowColor: '#00bcd4',
segmentColor: '#004d5c',
borderColor: '#003844'
},
light: {
backgroundColor: '#ffffff',
titleColor: '#424242',
subtitleColor: '#757575',
valuesColor: '#616161',
compassColor: '#424242',
arrowColor: '#424242',
segmentColor: '#e0e0e0',
borderColor: '#c5c5c5'
},
ocean: {
backgroundColor: '#e0f2f1',
titleColor: '#006064',
subtitleColor: '#00acc1',
valuesColor: '#00695c',
compassColor: '#006064',
arrowColor: '#00acc1',
segmentColor: 'rgba(0, 172, 193, 0.4)',
borderColor: 'rgba(0, 172, 193, 0.2)'
},
sunset: {
backgroundColor: '#fff3e0',
titleColor: '#bf360c',
subtitleColor: '#ff6f00',
valuesColor: '#e65100',
compassColor: '#e65100',
arrowColor: '#ff6f00',
segmentColor: 'rgba(255, 111, 0, 0.4)',
borderColor: 'rgba(255, 111, 0, 0.2)'
},
blur: {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
titleColor: '#ffffff',
subtitleColor: '#b3e5fc',
valuesColor: '#ffffff',
compassColor: '#e1f5fe',
arrowColor: '#81d4fa',
segmentColor: 'rgba(129, 212, 250, 0.3)',
borderColor: 'rgba(129, 212, 250, 0.15)',
backdropFilter: 'blur(10px)',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)'
},
neon: {
backgroundColor: '#0a0a0a',
titleColor: '#00ff88',
subtitleColor: '#ff0088',
valuesColor: '#00ffff',
compassColor: '#ffff00',
arrowColor: '#ff0088',
segmentColor: 'rgba(255, 0, 136, 0.3)',
borderColor: 'rgba(255, 0, 136, 0.15)',
boxShadow: '0 0 20px rgba(0, 255, 136, 0.5)'
},
forest: {
backgroundColor: '#f1f8e9',
titleColor: '#1b5e20',
subtitleColor: '#388e3c',
valuesColor: '#2e7d32',
compassColor: '#1b5e20',
arrowColor: '#4caf50',
segmentColor: 'rgba(76, 175, 80, 0.4)',
borderColor: 'rgba(76, 175, 80, 0.2)'
},
arctic: {
backgroundColor: '#e8f4f8',
titleColor: '#0d47a1',
subtitleColor: '#1976d2',
valuesColor: '#1565c0',
compassColor: '#0d47a1',
arrowColor: '#2196f3',
segmentColor: 'rgba(33, 150, 243, 0.3)',
borderColor: 'rgba(33, 150, 243, 0.15)'
},
volcano: {
backgroundColor: '#1a1a1a',
titleColor: '#ffeb3b',
subtitleColor: '#ff5722',
valuesColor: '#ffc107',
compassColor: '#ff9800',
arrowColor: '#f44336',
segmentColor: 'rgba(244, 67, 54, 0.4)',
borderColor: 'rgba(244, 67, 54, 0.2)',
boxShadow: '0 0 15px rgba(255, 87, 34, 0.3)'
},
evil: {
backgroundColor: '#0d1117',
titleColor: '#c9d1d9',
subtitleColor: '#8b5cf6',
valuesColor: '#f0f6fc',
compassColor: '#7c3aed',
arrowColor: '#a855f7',
segmentColor: 'rgba(213, 176, 255, 0.3)',
borderColor: 'rgba(168, 85, 247, 0.15)',
boxShadow: '0 0 25px rgba(139, 92, 246, 0.4)'
},
vintage: {
backgroundColor: '#faf0e6',
titleColor: '#8b4513',
subtitleColor: '#cd853f',
valuesColor: '#a0522d',
compassColor: '#8b4513',
arrowColor: '#daa520',
segmentColor: 'rgba(218, 165, 32, 0.4)',
borderColor: 'rgba(218, 165, 32, 0.2)'
},
mint: {
backgroundColor: '#f0fff4',
titleColor: '#006400',
subtitleColor: '#20b2aa',
valuesColor: '#228b22',
compassColor: '#2e8b57',
arrowColor: '#00ced1',
segmentColor: 'rgba(0, 206, 209, 0.3)',
borderColor: 'rgba(0, 206, 209, 0.15)'
},
cherry: {
backgroundColor: '#fff0f5',
titleColor: '#8b008b',
subtitleColor: '#ff1493',
valuesColor: '#c71585',
compassColor: '#8b008b',
arrowColor: '#ff69b4',
segmentColor: 'rgba(255, 105, 180, 0.3)',
borderColor: 'rgba(255, 105, 180, 0.15)'
}
};
return presets[preset] || {};
};
const presetStyles = applyThemePresets();
// Apply variant modifications
const getVariantStyles = () => {
const variants = {
compact: {
sizeMultiplier: 0.8,
hideCompass: true,
titleSizeMultiplier: 0.8,
subtitleSizeMultiplier: 0.8,
valuesSizeMultiplier: 0.8
},
detailed: {
sizeMultiplier: 1.2,
titleSizeMultiplier: 1.2,
subtitleSizeMultiplier: 1.2,
valuesSizeMultiplier: 1.2,
compassSizeMultiplier: 1.2
},
standard: {
sizeMultiplier: 1,
titleSizeMultiplier: 1,
subtitleSizeMultiplier: 1,
valuesSizeMultiplier: 1,
compassSizeMultiplier: 1
}
};
return variants[variant] || variants.standard;
};
const variantStyles = getVariantStyles();
const finalCircleSize = circleSize * variantStyles.sizeMultiplier;
// Angle calculations for segment
const normalizeAngle = (deg) => ((deg % 360) + 360) % 360;
const toRadians = (deg) => (deg * Math.PI) / 180;
const interpolateAngle = (start, target, progress) => {
let diff = target - start;
// Находим кратчайший путь
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
let result = start + diff * progress;
// Нормализуем результат
return normalizeAngle(result);
};
// Проверяет, находится ли угол внутри сегмента
const isAngleInSegment = (angle, minAngle, maxAngle) => {
const normalizedAngle = normalizeAngle(angle);
const normalizedMin = normalizeAngle(minAngle);
const normalizedMax = normalizeAngle(maxAngle);
if (normalizedMin <= normalizedMax) {
// Обычный сегмент
return normalizedAngle >= normalizedMin && normalizedAngle <= normalizedMax;
} else {
// Сегмент через 0°
return normalizedAngle >= normalizedMin || normalizedAngle <= normalizedMax;
}
};
const calculateSegmentBounds = () => {
if (!showSegment ||
direction === null || direction === undefined ||
minDirection === undefined || maxDirection === undefined) {
return { finalMinDirection: minDirection, finalMaxDirection: maxDirection };
}
const normalizedDirection = normalizeAngle(direction);
const normalizedMin = normalizeAngle(minDirection);
const normalizedMax = normalizeAngle(maxDirection);
if (autoExpandSegment) {
return expandSegmentToIncludeArrow(normalizedMin, normalizedMax, normalizedDirection);
} else {
return { finalMinDirection: normalizedMin, finalMaxDirection: normalizedMax };
}
};
const expandSegmentToIncludeArrow = (minAngle, maxAngle, arrowAngle) => {
// Основная логика с кратчайшим путем
const processWithShortestPath = (min, max, arrow) => {
const allAngles = [min, max, arrow];
let newMin = Math.min(...allAngles);
let newMax = Math.max(...allAngles);
let segmentSize = newMax - newMin;
if (segmentSize > 180) {
const sorted = allAngles.sort((a, b) => a - b);
let maxGap = 0;
let gapStart = 0;
let gapEnd = 0;
for (let i = 0; i < sorted.length - 1; i++) {
const gap = sorted[i + 1] - sorted[i];
if (gap > maxGap) {
maxGap = gap;
gapStart = sorted[i];
gapEnd = sorted[i + 1];
}
}
const wrapGap = (360 - sorted[sorted.length - 1]) + sorted[0];
if (wrapGap > maxGap) {
maxGap = wrapGap;
gapStart = sorted[sorted.length - 1];
gapEnd = sorted[0];
}
newMin = gapEnd;
newMax = gapStart;
}
return { finalMinDirection: newMin, finalMaxDirection: newMax };
};
// Альтернативная логика БЕЗ кратчайшего пути (raw arc)
const processWithoutShortestPath = (min, max, arrow) => {
// Проверяем, находится ли стрелка уже в сегменте
if (isAngleInSegment(arrow, min, max)) {
return { finalMinDirection: min, finalMaxDirection: max };
}
// Расширяем сегмент до стрелки минимальным способом
let clockwiseFromMax = normalizeAngle(arrow - max);
let counterClockwiseFromMin = normalizeAngle(min - arrow);
if (clockwiseFromMax <= counterClockwiseFromMin) {
return { finalMinDirection: min, finalMaxDirection: arrow };
} else {
return { finalMinDirection: arrow, finalMaxDirection: max };
}
};
// Сначала пробуем основную логику с кратчайшим путем
const result1 = processWithShortestPath(minAngle, maxAngle, arrowAngle);
// Проверяем, попала ли стрелка в результирующий сегмент
if (isAngleInSegment(arrowAngle, result1.finalMinDirection, result1.finalMaxDirection)) {
return result1;
}
// Если стрелка всё равно не попала, используем альтернативную логику БЕЗ кратчайшего пути
return processWithoutShortestPath(minAngle, maxAngle, arrowAngle);
};
const { finalMinDirection, finalMaxDirection } = calculateSegmentBounds();
// Animated states for smooth segment transitions
const [currentMin, setCurrentMin] = React.useState(finalMinDirection || 0);
const [currentMax, setCurrentMax] = React.useState(finalMaxDirection || 0);
// Animated state for smooth arrow transitions
const [currentArrowDirection, setCurrentArrowDirection] = React.useState(direction || 0);
// Animated state for smooth speed transitions
const [currentSpeed, setCurrentSpeed] = React.useState(speed || 0);
React.useEffect(() => {
if (enableSegmentAnimation && finalMinDirection !== undefined && finalMaxDirection !== undefined) {
const startMin = currentMin;
const startMax = currentMax;
const targetMin = finalMinDirection;
const targetMax = finalMaxDirection;
const duration = parseFloat(animationDuration) * 1000; // Convert to milliseconds
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing function (ease-out)
const easeOut = 1 - Math.pow(1 - progress, 3);
const newMin = interpolateAngle(startMin, targetMin, easeOut);
const newMax = interpolateAngle(startMax, targetMax, easeOut);
setCurrentMin(newMin);
setCurrentMax(newMax);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
} else if (!enableSegmentAnimation) {
// Мгновенное обновление без анимации
setCurrentMin(finalMinDirection || 0);
setCurrentMax(finalMaxDirection || 0);
}
}, [finalMinDirection, finalMaxDirection, animationDuration, enableSegmentAnimation]);
// Arrow animation with shortest path
React.useEffect(() => {
if (enableArrowAnimation && direction !== null && direction !== undefined) {
const startDirection = currentArrowDirection;
const targetDirection = direction;
const duration = parseFloat(animationDuration) * 1000; // Convert to milliseconds
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing function (ease-out)
const easeOut = 1 - Math.pow(1 - progress, 3);
const newDirection = interpolateAngle(startDirection, targetDirection, easeOut);
setCurrentArrowDirection(newDirection);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
} else if (!enableArrowAnimation) {
// Мгновенное обновление без анимации
setCurrentArrowDirection(direction || 0);
}
}, [direction, animationDuration, enableArrowAnimation]);
// Speed animation
React.useEffect(() => {
if (enableTextAnimation && speed !== null && speed !== undefined) {
const startSpeed = currentSpeed;
const targetSpeed = speed;
const duration = parseFloat(animationDuration) * 1000; // Convert to milliseconds
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing function (ease-out)
const easeOut = 1 - Math.pow(1 - progress, 3);
const newSpeed = startSpeed + (targetSpeed - startSpeed) * easeOut;
setCurrentSpeed(newSpeed);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
} else if (!enableTextAnimation) {
// Мгновенное обновление без анимации
setCurrentSpeed(speed || 0);
}
}, [speed, animationDuration, enableTextAnimation]);
// Calculate arrow properties using animated or static speed
const currentDisplaySpeed = enableTextAnimation ? currentSpeed : speed;
const currentDisplayDirection = enableArrowAnimation ? currentArrowDirection : direction;
const circleRadius = finalCircleSize * 0.67; // Responsive radius
const arrowBaseOffset = circleRadius * 0.75;
const arrowMaxLength = circleRadius * maxArrowLength;
const arrowMinLength = circleRadius * minArrowLength;
const arrowLength = Math.max(
arrowMinLength,
Math.min(arrowMaxLength, (currentDisplaySpeed / maxSpeed) * arrowMaxLength)
);
const arrowOffset = arrowBaseOffset - arrowLength;
// Get arrow color using animated or static speed
const getArrowColor = () => {
// Priority 1: Preset colors
if (preset && presetStyles.arrowColor) return presetStyles.arrowColor;
// Priority 2: Custom arrowColor prop
if (arrowColor) return arrowColor;
// Priority 3: Error state for invalid data
if (currentDisplaySpeed === null || currentDisplaySpeed === undefined || isNaN(currentDisplaySpeed)) {
return theme.palette.error.main;
}
// Priority 4: Default theme primary color
return theme.palette.primary.main;
};
// Get segment color
const getSegmentColor = () => {
if (preset && presetStyles.segmentColor) return presetStyles.segmentColor;
return segmentColor || theme.palette.primary.main;
};
// Get subtitle color
const getSubtitleColor = () => {
if (preset && presetStyles.subtitleColor) return presetStyles.subtitleColor;
return subtitleColor || theme.palette.primary.main;
};
// Get title color
const getTitleColor = () => {
if (preset && presetStyles.titleColor) return presetStyles.titleColor;
return titleColor || theme.palette.text.primary;
};
// Get values color
const getValuesColor = () => {
if (preset && presetStyles.valuesColor) return presetStyles.valuesColor;
return valuesColor || theme.palette.text.primary;
};
// Get background color
const getBackgroundColor = () => {
if (preset && presetStyles.backgroundColor) return presetStyles.backgroundColor;
return backgroundColor || theme.palette.background.paper;
};
// Get compass color
const getCompassColor = () => {
if (preset && presetStyles.compassColor) return presetStyles.compassColor;
return compassColor || theme.palette.text.primary;
};
// Get border color
const getBorderColor = () => {
if (preset && presetStyles.borderColor) return presetStyles.borderColor;
return borderColor || theme.palette.divider;
};
const createSegmentArc = () => {
if (!showSegment || currentMin == null || currentMax == null) {
return null;
}
const thickness = segmentThickness || 8;
const minA = normalizeAngle(currentMin);
const maxA = normalizeAngle(currentMax);
const arrow = normalizeAngle(currentDisplayDirection);
let startDeg, spanDeg;
if (autoExpandSegment) {
const { finalMinDirection: expandedMin, finalMaxDirection: expandedMax } =
expandSegmentToIncludeArrow(minA, maxA, arrow);
startDeg = expandedMin;
spanDeg = (expandedMax - expandedMin + 360) % 360;
} else if (enableRawArc) {
startDeg = minA;
spanDeg = (maxA - minA + 360) % 360;
} else {
const sizeA = (maxA - minA + 360) % 360;
const sizeB = 360 - sizeA;
const inArc = (start, size, angle) => {
const rel = (angle - start + 360) % 360;
return rel <= size;
};
if (inArc(minA, sizeA, arrow)) {
startDeg = minA;
spanDeg = sizeA;
} else {
startDeg = maxA;
spanDeg = sizeB;
}
}
const startRad = toRadians(startDeg);
const endRad = toRadians(startDeg + spanDeg);
return arc()
.innerRadius(circleRadius - thickness)
.outerRadius(circleRadius)
.startAngle(startRad)
.endAngle(endRad);
};
const segmentArc = createSegmentArc();
const defaultLabels = ["N", "E", "S", "W"];
const compassLabels = customCompassLabels || defaultLabels;
const shouldShowCompass = showCompass && !variantStyles.hideCompass;
const compassDirections = shouldShowCompass ? [
{ label: compassLabels[0], x: finalCircleSize * 0.67, y: finalCircleSize * 0.13 },
{ label: compassLabels[1], x: finalCircleSize * 1.23, y: finalCircleSize * 0.70 },
{ label: compassLabels[2], x: finalCircleSize * 0.67, y: finalCircleSize * 1.27 },
{ label: compassLabels[3], x: finalCircleSize * 0.10, y: finalCircleSize * 0.70 },
] : [];
const viewBoxSize = finalCircleSize * 1.33;
return (
<Box
sx={{
width: finalCircleSize,
height: finalCircleSize,
position: "relative",
...(enableHoverEffects && {
'&:hover': {
transform: 'scale(1.05)',
transition: 'transform 0.2s ease-in-out'
}
}),
...customStyles.container
}}
onClick={onClick}
onMouseEnter={onHover}
title={tooltip}
>
<Paper
variant="outlined"
sx={{
borderRadius: "50%",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: `${borderWidth}px ${borderStyle} ${getBorderColor()}`,
position: "relative",
overflow: "hidden",
borderColor: getBorderColor(),
backgroundColor: getBackgroundColor(),
...customStyles.paper
}}
>
{/* Arrow */}
{direction !== null && direction !== undefined && (
<Box
sx={{
position: "absolute",
width: "100%",
height: "100%",
transformOrigin: "50% 50%",
transform: `rotate(${currentDisplayDirection - 180}deg) translateY(${arrowOffset}px)`,
transition: 'none', // Убираем CSS transition, используем JavaScript анимацию
opacity: arrowOpacity,
...(arrowShadow && { filter: `drop-shadow(${arrowShadow})` }),
...customStyles.arrow
}}
>
{/* Arrow tail */}
<Box
sx={{
position: "absolute",
top: "calc(50% + 12px)",
left: "50%",
transform: "translateX(-50%) translateY(-10%)",
width: `${arrowThickness}px`,
height: `${finalCircleSize}px`,
backgroundColor: getArrowColor(),
}}
/>
{/* Arrow head */}
<NavigationIcon
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
fontSize: arrowHeadSize ? finalCircleSize * 0.15 * arrowHeadSize : finalCircleSize * 0.15,
color: getArrowColor(),
}}
/>
</Box>
)}
{/* Segment and compass directions */}
<svg
width="100%"
height="100%"
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
style={{ position: "absolute", top: 0, left: 0 }}
>
<g transform={`translate(${viewBoxSize/2},${viewBoxSize/2})`}>
{segmentArc && (
<path
d={segmentArc()}
fill={getSegmentColor()}
opacity={segmentOpacity}
/>
)}
</g>
{compassDirections.map(({ label, x, y }) => (
<text
key={label}
x={x}
y={y}
textAnchor="middle"
fontSize={compassSize || (finalCircleSize * 0.12 * variantStyles.compassSizeMultiplier)}
fill={getCompassColor()}
opacity={compassOpacity}
fontWeight={compassWeight}
>
{label}
</text>
))}
</svg>
{/* Subtitle */}
{showSubtitle && subtitle && (
<Typography
sx={{
mb: finalCircleSize * 0.04,
color: getSubtitleColor(),
fontSize: (subtitleSize && typeof subtitleSize === 'string') ? subtitleSize :
(subtitleSize && typeof subtitleSize === 'number') ? `${subtitleSize}px` :
finalCircleSize * 0.08 * variantStyles.subtitleSizeMultiplier,
fontWeight: subtitleWeight,
...customStyles.subtitle
}}
variant="caption"
>
{subtitle}
</Typography>
)}
{/* Speed and direction display */}
{(showSpeed || showDirection) && (
<Typography
variant="caption"
sx={{
textAlign: "center",
position: "absolute",
bottom: finalCircleSize * 0.16,
color: getValuesColor(),
padding: "0px 6px",
borderRadius: "0px",
fontSize:
typeof valuesSize === "string"
? valuesSize
: typeof valuesSize === "number"
? `${valuesSize}px`
: finalCircleSize * 0.08 * variantStyles.valuesSizeMultiplier,
fontWeight: valuesWeight,
...customStyles.speedText,
}}
>
{/* SPEED */}
{showSpeed && (() => {
if (speed == null || isNaN(speed)) {
if (hideNullValues) return null;
return (
<>
--<br />
{secondarySpeedUnit && <>--<br /></>}
</>
);
}
const val = enableTextAnimation ? currentDisplaySpeed : speed;
return (
<>
{`${val.toFixed(1)} ${speedUnit}`}<br />
{secondarySpeedUnit && (
<>
{`${(val * speedUnitMultiplier).toFixed(1)} ${secondarySpeedUnit}`}<br />
</>
)}
</>
);
})()}
{/* DIRECTION */}
{showDirection && (() => {
if (direction == null || isNaN(direction)) {
return hideNullValues ? null : "--";
}
const dir = enableTextAnimation ? currentArrowDirection : direction;
return `${dir.toFixed(0)}°`;
})()}
</Typography>
)}
</Paper>
{/* Title */}
{showTitle && title && (
<Typography
marginTop={0.5}
textAlign="center"
sx={{
display: "flex",
justifyContent: "center",
color: getTitleColor(),
fontSize: (titleSize && typeof titleSize === 'string') ? titleSize :
(titleSize && typeof titleSize === 'number') ? `${titleSize}px` :
`${finalCircleSize * 0.08 * variantStyles.titleSizeMultiplier}px`,
fontWeight: titleWeight,
...customStyles.title
}}
>
{title}
</Typography>
)}
</Box>
);
};
export default SimpleWindIndicator;