DEV Community

zhonghua
zhonghua

Posted on • Edited on

HarmonyOS Sports Development: How to Draw Sports Speed Trajectories

HarmonyOS Sports Development: How to Draw Sports Speed Trajectories

Foreword

In outdoor sports applications, drawing sports speed trajectories can not only intuitively display the user's sports route but also reflect changes in speed through color variations, helping users better understand their sports status. However, how can we implement this feature in the HarmonyOS system? This article will combine practical development experience to deeply analyze the entire process from data processing to map drawing, guiding you step by step on how to draw sports speed trajectories.

Image description

I. Core Tools: Trajectory Colors and Optimization

The key to drawing sports speed trajectories lies in two tool classes: PathGradientTool and PathSmoothTool. These two tool classes are used to handle the colors of the trajectories and optimize the smoothness of the trajectories, respectively.

  1. Trajectory Color Tool Class: PathGradientTool

The role of PathGradientTool is to assign colors to trajectory points based on sports speed. The faster the speed, the closer the color is to cyan; the slower the speed, the closer the color is to red. Below is the core logic of PathGradientTool:

export class PathGradientTool {
  /**
   * Get the path coloring array
   * @param points Trajectory point data
   * @param colorInterval Coloring interval, unit m, range 20-2000, how often to set a color over a certain distance
   * @returns Path coloring array
   */
  static getPathColors(points: RunPoint[], colorInterval: number): string[] | null {
    if (!points || points.length < 2) {
      return null;
    }

    let interval = Math.max(20, Math.min(2000, colorInterval));
    const pointsSize = points.length;
    const speedList: number[] = [];
    const colorList: string[] = [];
    let index = 0;
    let lastDistance = 0;
    let lastTime = 0;
    let maxSpeed = 0;
    let minSpeed = 0;

    // First pass: Collect speed data
    points.forEach(point => {
      index++;
      if (point.totalDistance - lastDistance > interval) {
        let currentSpeed = 0;
        if (point.netDuration - lastTime > 0) {
          currentSpeed = (point.netDistance - lastDistance) / (point.netDuration - lastTime);
        }
        maxSpeed = Math.max(maxSpeed, currentSpeed);
        minSpeed = minSpeed === 0 ? currentSpeed : Math.min(minSpeed, currentSpeed);
        lastDistance = point.netDistance;
        lastTime = point.netDuration;

        // Add the same speed to each point within the interval
        for (let i = 0; i < index; i++) {
          speedList.push(currentSpeed);
        }
        // Add a barrier
        speedList.push(Number.MAX_VALUE);
        index = 0;
      }
    });

    // Handle remaining points
    if (index > 0) {
      const lastPoint = points[points.length - 1];
      let currentSpeed = 0;
      if (lastPoint.netDuration - lastTime > 0) {
        currentSpeed = (lastPoint.netDistance - lastDistance) / (lastPoint.netDuration - lastTime);
      }
      for (let i = 0; i < index; i++) {
        speedList.push(currentSpeed);
      }
    }

    // Ensure the speed list length matches the number of points
    if (speedList.length !== points.length) {
      // Adjust the speed list length
      if (speedList.length > points.length) {
        speedList.length = points.length;
      } else {
        const lastSpeed = speedList.length > 0 ? speedList[speedList.length - 1] : 0;
        while (speedList.length < points.length) {
          speedList.push(lastSpeed);
        }
      }
    }

    // Generate the color list
    let lastColor = '';
    let hasBarrier = false;
    for (let i = 0; i < speedList.length; i++) {
      const speed = speedList[i];
      if (speed === Number.MAX_VALUE) {
        hasBarrier = true;
        continue;
      }

      const color = PathGradientTool.getAgrSpeedColorHashMap(speed, maxSpeed, minSpeed);
      if (hasBarrier) {
        hasBarrier = false;
        if (color.toUpperCase() === lastColor.toUpperCase()) {
          colorList.push(PathGradientTool.getBarrierColor(color));
          continue;
        }
      }
      colorList.push(color);
      lastColor = color;
    }

    // Ensure the color list length matches the number of points
    if (colorList.length !== points.length) {
      if (colorList.length > points.length) {
        colorList.length = points.length;
      } else {
        const lastColor = colorList.length > 0 ? colorList[colorList.length - 1] : '#FF3032';
        while (colorList.length < points.length) {
          colorList.push(lastColor);
        }
      }
    }

    return colorList;
  }

  /**
   * Define different color ranges based on speed to draw the trajectory
   * @param speed Speed
   * @param maxSpeed Maximum speed
   * @param minSpeed Minimum speed
   * @returns Color value
   */
  private static getAgrSpeedColorHashMap(speed: number, maxSpeed: number, minSpeed: number): string {
    const range = maxSpeed - minSpeed;
    if (speed <= minSpeed + range * 0.2) { // 0-20% speed range
      return '#FF3032';
    } else if (speed <= minSpeed + range * 0.4) { // 20%-40% speed range
      return '#FA7B22';
    } else if (speed <= minSpeed + range * 0.6) { // 40%-60% speed range
      return '#F5BE14';
    } else if (speed <= minSpeed + range * 0.8) { // 60%-80% speed range
      return '#7AC36C';
    } else { // 80%-100% speed range
      return '#00C8C3';
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Trajectory Optimization Tool Class: PathSmoothTool

The role of PathSmoothTool is to optimize the smoothness of the trajectory, reducing noise and redundancy in trajectory points. Below is the core logic of PathSmoothTool:

export class PathSmoothTool {
  private mIntensity: number = 3;
  private mThreshhold: number = 0.01;
  private mNoiseThreshhold: number = 10;

  /**
   * Trajectory smoothing optimization
   * @param originlist Original trajectory list, list.size greater than 2
   * @returns Optimized trajectory list
   */
  pathOptimize(originlist: RunLatLng[]): RunLatLng[] {
    const list = this.removeNoisePoint(originlist); // Noise reduction
    const afterList = this.kalmanFilterPath(list, this.mIntensity); // Filtering
    const pathoptimizeList = this.reducerVerticalThreshold(afterList, this.mThreshhold); // Thinning
    return pathoptimizeList;
  }

  /**
   * Trajectory line filtering
   * @param originlist Original trajectory list, list.size greater than 2
   * @returns Filtered trajectory list
   */
  kalmanFilterPath(originlist: RunLatLng[], intensity: number = this.mIntensity): RunLatLng[] {
    const kalmanFilterList: RunLatLng[] = [];
    if (!originlist || originlist.length <= 2) return kalmanFilterList;

    this.initial(); // Initialize filter parameters
    let lastLoc = originlist[0];
    kalmanFilterList.push(lastLoc);

    for (let i = 1; i < originlist.length; i++) {
      const curLoc = originlist[i];
      const latLng = this.kalmanFilterPoint(lastLoc, curLoc, intensity);
      if (latLng) {
        kalmanFilterList.push(latLng);
        lastLoc = latLng;
      }
    }
    return kalmanFilterList;
  }

  /**
   * Single point filtering
   * @param lastLoc Last location point coordinates
   * @param curLoc Current location point coordinates
   * @returns Filtered current location point coordinates
   */
  kalmanFilterPoint(lastLoc: RunLatLng, curLoc: RunLatLng, intensity: number = this.mIntensity): RunLatLng | null {
    if (this.pdelt_x === 0 || this.pdelt_y === 0) {
      this.initial();
    }

    if (!lastLoc || !curLoc) return null;

    intensity = Math.max(1, Math.min(5, intensity));
    let filteredLoc = curLoc;

    for (let j = 0; j < intensity; j++) {
      filteredLoc = this.kalmanFilter(lastLoc.longitude, filteredLoc.longitude, lastLoc.latitude, filteredLoc.latitude);
    }

    return filteredLoc;
  }

  /**
   * Trajectory thinning
   * @param inPoints Trajectory list to be thinned
   * @param threshHold Threshold
   * @returns Thinned trajectory list
   */
  private reducerVerticalThreshold(inPoints: RunLatLng[], threshHold: number): RunLatLng[] {
    if (!inPoints || inPoints.length <= 2) return inPoints || [];

    const ret: RunLatLng[] = [];
    for (let i = 0; i < inPoints.length; i++) {
      const pre = this.getLastLocation(ret);
      const cur = inPoints[i];

      if (!pre || i === inPoints.length - 1) {
        ret.push(cur);
        continue;
      }

      const next = inPoints[i + 1];
      const distance = this.calculateDistanceFromPoint(cur, pre, next);
      if (distance > threshHold) {
        ret.push(cur);
      }
    }
    return ret;
  }

  /**
   * Trajectory noise reduction
   * @param inPoints Original trajectory list
   * @returns Noise-reduced trajectory list
   */
  removeNoisePoint(inPoints: RunLatLng[]): RunLatLng[] {
    if (!inPoints || inPoints.length <= 2) return inPoints || [];

    const ret: RunLatLng[] = [];
    for (let i = 0; i < inPoints.length; i++) {
      const pre = this.getLastLocation(ret);
      const cur = inPoints[i];

      if (!pre || i === inPoints.length - 1) {
        ret.push(cur);
        continue;
      }

      const next = inPoints[i + 1];
      const distance = this.calculateDistanceFromPoint(cur, pre, next);
      if (distance < this.mNoiseThreshhold) {
        ret.push(cur);
      }
    }
    return ret;
  }

  /**
   * Get the last location point
   */
  private getLastLocation(points: RunLatLng[]): RunLatLng | null {
    if (!points || points.length === 0) return null;
    return points[points.length - 1];
  }

  /**
   * Calculate the perpendicular distance from a point to a line
   */
  private calculateDistanceFromPoint(p: RunLatLng, lineBegin: RunLatLng, lineEnd: RunLatLng): number {
    const A = p.longitude - lineBegin.longitude;
    const B = p.latitude - lineBegin.latitude;
    const C = lineEnd.longitude - lineBegin.longitude;
    const D = lineEnd.latitude - lineBegin.latitude;
    const dot = A * C + B * D;
    const len_sq = C * C + D * D;
    const param = dot / len_sq;

    let xx: number, yy: number;
    if (param < 0 || (lineBegin.longitude === lineEnd.longitude && lineBegin.latitude === lineEnd.latitude)) {
      xx = lineBegin.longitude;
      yy = lineBegin.latitude;
    } else if (param > 1) {
      xx = lineEnd.longitude;
      yy = lineEnd.latitude;
    } else {
      xx = lineBegin.longitude + param * C;
      yy = lineBegin.latitude + param * D;
    }

    const point = new RunLatLng(yy, xx);
    return this.calculateLineDistance(p, point);
  }

  /**
   * Calculate the distance between two points
   */
  private calculateLineDistance(point1: RunLatLng, point2: RunLatLng): number {
    const EARTH_RADIUS = 6378137.0;
    const lat1 = this.rad(point1.latitude);
    const lat2 = this.rad(point2.latitude);
    const a = lat1 - lat2;
    const b = this.rad(point1.longitude) - this.rad(point2.longitude);
    const s = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a / 2), 2) +
      Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin(b / 2), 2)));
    return s * EARTH_RADIUS;
  }

  /**
   * Convert degrees to radians
   */
  private rad(d: number): number {
    return d * Math.PI / 180.0;
  }

  /**
   * Trajectory thinning (processing source data simultaneously)
   * @param inPoints Trajectory list to be thinned
   * @param sourcePoints Source data list, corresponding to inPoints one by one
   * @param threshHold Threshold
   * @returns A tuple containing the thinned trajectory list and the corresponding source data list
   */
  reducerVerticalThresholdWithSource<T>(inPoints: RunLatLng[], sourcePoints: T[], threshHold: number = this.mThreshhold): { points: RunLatLng[], sources: T[] } {
    if (!inPoints || !sourcePoints || inPoints.length <= 2 || inPoints.length !== sourcePoints.length) {
      return { points: inPoints || [], sources: sourcePoints || [] };
    }

    const retPoints: RunLatLng[] = [];
    const retSources: T[] = [];

    for (let i = 0; i < inPoints.length; i++) {
      const pre = this.getLastLocation(retPoints);
      const cur = inPoints[i];

      if (!pre || i === inPoints.length - 1) {
        retPoints.push(cur);
        retSources.push(sourcePoints[i]);
        continue;
      }

      const next = inPoints[i + 1];
      const distance = this.calculateDistanceFromPoint(cur, pre, next);
      if (distance > threshHold) {
        retPoints.push(cur);
        retSources.push(sourcePoints[i]);
      }
    }

    return { points: retPoints, sources: retSources };
  }
}
Enter fullscreen mode Exit fullscreen mode

II. Drawing Sports Speed Trajectories

With the above two tool classes, we can now start drawing sports speed trajectories. Below is the complete process for drawing the trajectory:

  1. Prepare Trajectory Point Data

First, convert the original trajectory point data into a RunLatLng array for subsequent processing:

// Convert trajectory points to RunLatLng array for optimization
let tempTrackPoints = this.record!.points.map(point => new RunLatLng(point.latitude, point.longitude));
Enter fullscreen mode Exit fullscreen mode
  1. Optimize Trajectory Points

Use PathSmoothTool to optimize the trajectory points, including noise reduction, filtering, and thinning. To ensure the correctness of the source data, I only performed thinning here:

// Trajectory optimization
const pathSmoothTool = new PathSmoothTool();
const optimizedPoints = pathSmoothTool.reducerVerticalThresholdWithSource<RunPoint>(tempTrackPoints, this.record!.points);
Enter fullscreen mode Exit fullscreen mode
  1. Convert to Map Display Format

Convert the optimized trajectory points into the LatLng format required by the map:

// Convert the optimized points to LatLng array for map display
this.trackPoints = optimizedPoints.points.map(point => new LatLng(point.latitude, point.longitude));
Enter fullscreen mode Exit fullscreen mode
  1. Get Trajectory Color Array

Use PathGradientTool to generate a color array for the trajectory points based on speed:

// Get the trajectory color array
const colors = PathGradientTool.getPathColors(optimizedPoints.sources, 100);
Enter fullscreen mode Exit fullscreen mode
  1. Draw Trajectory Line

Pass the trajectory points and color array to the map component to draw the trajectory line:

if (this.trackPoints.length > 0) {
  // Set the map center point to the first point
  this.mapController.setMapCenter({
    lat: this.trackPoints[0].lat,
    lng: this.trackPoints[0].lng
  }, 15);

  // Create the trajectory line
  this.polyline = new Polyline({
    points: this.trackPoints,
    width: 5,
    join: SysEnum.LineJoinType.ROUND,
    cap: SysEnum.LineCapType.ROUND,
    isGradient: true,
    colorList: colors
  });

  // Add the trajectory line to the map
  this.mapController.addOverlay(this.polyline);
}
Enter fullscreen mode Exit fullscreen mode

III. Core Points of the Code

  1. Trajectory Color Calculation

PathGradientTool assigns colors to trajectory points based on speed ranges. The faster the speed, the closer the color is to cyan; the slower the speed, the closer the color is to red. The color gradient is achieved through the getGradient method.

  1. Trajectory Optimization

PathSmoothTool uses the Kalman filter algorithm to filter trajectory points, reducing noise and redundant points. Trajectory thinning is achieved through a vertical distance threshold, reducing the number of trajectory points and improving drawing performance.

  1. Map Drawing

Using Baidu Map components (such as Polyline) to draw trajectory lines and implementing color gradient effects through colorList. The map center point is set to the starting point of the trajectory to ensure the complete display of the trajectory.

IV. Summary and Outlook

Through the above steps, we have successfully implemented the drawing of sports speed trajectories. The trajectory colors reflect changes in speed, and the optimized trajectory is smoother and more performance-efficient.

Top comments (0)