UNPKG

simple-wind-indicator

Version:

Universal wind direction and speed indicator component for React

914 lines (810 loc) 39.2 kB
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;