/**
* Responsive design utilities for VChart components
*/
import type { IVChartOption } from '@visactor/vchart';
import { DEFAULT_BREAKPOINTS, type ResponsiveBreakpoints } from './types';
/**
* Device type based on viewport width
*/
export type DeviceType = 'mobile' | 'tablet' | 'desktop';
/**
* Get current device type
*/
export function getDeviceType(
width: number,
breakpoints: ResponsiveBreakpoints = DEFAULT_BREAKPOINTS
): DeviceType {
if (width < breakpoints.mobile) {
return 'mobile';
} else if (width < breakpoints.tablet) {
return 'tablet';
} else {
return 'desktop';
}
}
/**
* Get responsive font size
*/
export function getResponsiveFontSize(
baseSize: number,
deviceType: DeviceType
): number {
switch (deviceType) {
case 'mobile':
return Math.max(10, baseSize * 0.7);
case 'tablet':
return Math.max(12, baseSize * 0.85);
case 'desktop':
default:
return baseSize;
}
}
/**
* Get responsive spacing
*/
export function getResponsiveSpacing(
baseSpacing: number,
deviceType: DeviceType
): number {
switch (deviceType) {
case 'mobile':
return Math.max(4, baseSpacing * 0.5);
case 'tablet':
return Math.max(6, baseSpacing * 0.75);
case 'desktop':
default:
return baseSpacing;
}
}
/**
* Apply responsive adjustments to chart spec
*/
export function applyResponsiveAdjustments(
spec: IVChartOption,
width: number,
height: number,
breakpoints?: ResponsiveBreakpoints
): IVChartOption {
const deviceType = getDeviceType(width, breakpoints);
const adjustedSpec = { ...spec };
// Adjust title font size
if (adjustedSpec.title && typeof adjustedSpec.title !== 'boolean') {
const titleFontSize = adjustedSpec.title.textStyle?.fontSize || 20;
adjustedSpec.title = {
...adjustedSpec.title,
textStyle: {
...adjustedSpec.title.textStyle,
fontSize: getResponsiveFontSize(titleFontSize as number, deviceType)
}
};
}
// Adjust axis label sizes
if (adjustedSpec.axes) {
adjustedSpec.axes = adjustedSpec.axes.map(axis => ({
...axis,
label: {
...axis.label,
style: {
...axis.label?.style,
fontSize: getResponsiveFontSize(
(axis.label?.style?.fontSize as number) || 12,
deviceType
)
}
},
title: axis.title
? {
...axis.title,
style: {
...axis.title.style,
fontSize: getResponsiveFontSize(
(axis.title.style?.fontSize as number) || 14,
deviceType
)
}
}
: axis.title
}));
}
// Adjust legend for mobile
if (adjustedSpec.legends && deviceType === 'mobile') {
adjustedSpec.legends = adjustedSpec.legends.map(legend => ({
...legend,
orient: 'bottom',
item: {
...legend.item,
style: {
...legend.item?.style,
fontSize: getResponsiveFontSize(
(legend.item?.style?.fontSize as number) || 12,
deviceType
)
}
}
}));
}
// Adjust padding
const basePadding = 20;
const padding = getResponsiveSpacing(basePadding, deviceType);
adjustedSpec.padding = padding;
// For pie charts, adjust radius on mobile
if (spec.type === 'pie' && deviceType === 'mobile') {
adjustedSpec.radius = 0.6;
adjustedSpec.innerRadius = 0.3;
}
// For bar charts, adjust label visibility on mobile
if (spec.type === 'bar' && deviceType === 'mobile') {
if (adjustedSpec.label) {
adjustedSpec.label = {
...adjustedSpec.label,
visible: false // Hide labels on mobile for cleaner look
};
}
}
return adjustedSpec;
}
/**
* Create responsive observer for chart container
*/
export function createResponsiveObserver(
element: HTMLElement,
callback: (width: number, height: number) => void
): ResizeObserver {
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
callback(width, height);
}
});
observer.observe(element);
return observer;
}
/**
* Get optimal chart height based on width and device type
*/
export function getOptimalHeight(
width: number,
deviceType?: DeviceType
): number {
const type = deviceType || getDeviceType(width);
switch (type) {
case 'mobile':
return Math.min(width * 1.2, 400);
case 'tablet':
return Math.min(width * 0.75, 500);
case 'desktop':
default:
return Math.min(width * 0.6, 600);
}
}
/**
* Responsive configuration for different chart types
*/
export const RESPONSIVE_CONFIG = {
mobile: {
maxPointSize: 4,
maxLineWidth: 2,
labelRotation: 45,
legendMaxWidth: '100%',
tooltipMaxWidth: 200
},
tablet: {
maxPointSize: 6,
maxLineWidth: 3,
labelRotation: 30,
legendMaxWidth: '80%',
tooltipMaxWidth: 300
},
desktop: {
maxPointSize: 8,
maxLineWidth: 4,
labelRotation: 0,
legendMaxWidth: '60%',
tooltipMaxWidth: 400
}
} as const;
/**
* Get responsive configuration for device type
*/
export function getResponsiveConfig(deviceType: DeviceType) {
return RESPONSIVE_CONFIG[deviceType];
}
/**
* Check if device supports touch
*/
export function isTouchDevice(): boolean {
return (
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
(navigator as any).msMaxTouchPoints > 0
);
}
/**
* Get optimized animation duration based on device
*/
export function getAnimationDuration(deviceType: DeviceType): number {
switch (deviceType) {
case 'mobile':
return 500; // Faster animations on mobile
case 'tablet':
return 750;
case 'desktop':
default:
return 1000;
}
}
/**
* Apply mobile-specific optimizations
*/
export function applyMobileOptimizations(spec: IVChartOption): IVChartOption {
const optimized = { ...spec };
// Reduce animation complexity
if (optimized.animation) {
optimized.animation = {
...optimized.animation,
appear: {
duration: 500,
easing: 'linear'
}
};
}
// Simplify tooltips
if (optimized.tooltip) {
optimized.tooltip = {
...optimized.tooltip,
trigger: 'click' // Use click instead of hover on mobile
};
}
// Disable crosshairs on mobile
if (optimized.crosshair) {
optimized.crosshair = {
xField: { visible: false },
yField: { visible: false }
};
}
return optimized;
}