WebGIS性能监测demo

发布于 2025-09-23  829 次阅读


顺着上一篇设计思路,开始做demo。

mapbox版本选择2.15,尽量不侵入式修改源码。
项目选用ts+webpack框架。

项目初始化

第一次做一个完整的ts的模块,记录一下初始化

首先

# 初始化 npm 项目
npm init -y

# 安装 TypeScript
npm install -D typescript

# 安装类型定义
npm install -D @types/node

# 创建基本目录结构
mkdir src
mkdir dist
mkdir public

之后配置 TypeScript,创建 tsconfig.json文件:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "node",
    "lib": ["DOM", "ES2020"],
    "allowJs": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

之后安装webpack

npm install -D webpack webpack-cli webpack-dev-server
npm install -D ts-loader html-webpack-plugin
npm install -D css-loader style-loader

创建 webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  mode: 'development',
  entry: './src/index.ts',
  devtool: 'inline-source-map',
  devServer: {
    static: './',
    hot: true,
    open: true
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
      {
        test: /\.css$/i,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'TypeScript Project',
      template: 'index.html'
    }),
  ],
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
    clean: true,
    publicPath:'./'  // 修改为相对路径
  },
};

之后在根目录创建index.html作为测试页面,src/index.ts作为入口。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Mapbox 性能监测工具</title>
    <link href='https://api.mapbox.com/mapbox-gl-js/v3.15.0/mapbox-gl.css' rel='stylesheet' />
    <style>
        body { 
            margin: 0; 
            padding: 0; 
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        }
        #map {
            width: 100vw;
            height: 100vh;
        }
    </style>
</head>
<body>
    <div id="map"></div>

    <!-- Mapbox GL JS -->
    <script src='https://api.mapbox.com/mapbox-gl-js/v3.15.0/mapbox-gl.js'></script>
    
    <!-- 打包后的脚本 -->
</body>
</html>
// 引入必要的模块
import mapboxgl from 'mapbox-gl'
import { PerformanceMonitor } from './core/PerformanceMonitor';

// 设置Mapbox访问令牌
mapboxgl.accessToken = '-';

// 创建性能监测器实例
let performanceMonitor: PerformanceMonitor;

performanceMonitor = new PerformanceMonitor();

// 初始化地图
const map = new mapboxgl.Map({
    container: 'map',
    style: 'mapbox://styles/mapbox/streets-v11',
    center: [116.46, 39.92], // 默认中心点(北京)
    zoom: 10
});

执行build、dev,http://localhost:8080/dist/index.html地址正常显示地图。

模块代码结构设计

初步设计为如下结构,边做边改吧

mapbox-performance-tool/
├── src/
│   ├── index.ts                 # 应用入口点
│   ├── core/
│   │   └── PerformanceMonitor.ts # 主性能监测类
│   ├── metrics/
│   │   ├── mapConstructorTime.ts  # 开图时间相关指标
│   │   ├── FrameMetric.ts   # 帧相关指标
│   │   ├── UserActionMetric.ts # 用户操作指标
│   │   ├── MapStateMetric.ts # 地图状态指标
│   │   ├── DataLoadMetric.ts # 数据加载指标
│   │   └── SystemMetric.ts  # 系统资源指标
│   ├── recorder/
│   │   └── DataRecorder.ts  # 数据记录器
│   └── simulator/
│   │   └── InputSimulator.ts # 输入模拟器
│   └── hooker/
│   │   └── hooker.ts # 钩子类,用于埋点
├── webpack.config.js       # Webpack配置
├── dist/                       # 构建输出目录
├── index.html                  # 主HTML文件
├── package.json
└── tsconfig.json

指标监测方法

开图时间监测

先以开图时间这个最简单的指标开始模块的开发
首先我们尽可能不去依赖于入口类,因为作为一个性能监控插件,我们不能每次都能干涉到用户项目的入口类,最好能做到,只在入口类new一下我们的PerformanceMonitor类,就可以进行监控。那么第一个问题就是怎么获取map类的开始创建时间,因为map有事件机制来获取数据加载(load)和渲染完毕地图空闲(idle)的时间。最优雅的方法就是用hook去在map构造函数前后加上事件,实现如下:

//钩子方法类,用于在mapbox源码中埋点
import { BlobOptions } from 'buffer';
import mapboxgl from 'mapbox-gl'

// 定义自定义事件接口
interface MapConstructStartEvent extends CustomEvent {
  detail: {
    timestamp: number;
  };
}

interface MapConstructCompleteEvent extends CustomEvent {
  detail: {
    map: mapboxgl.Map;
    timestamp: number;
  };
}

// 扩展Window接口以包含自定义事件
declare global {
  interface Window {
    addEventListener(
      type: 'mapConstructStart',
      listener: (event: MapConstructStartEvent) => void,
      options?: boolean | AddEventListenerOptions
    ): void;
    addEventListener(
      type: 'mapConstructComplete',
      listener: (event: MapConstructCompleteEvent) => void,
      options?: boolean | AddEventListenerOptions
    ): void;
    dispatchEvent(event: MapConstructStartEvent | MapConstructCompleteEvent): boolean;
  }
}

//单例类
export class Hooker{
  // 饿汉模式:直接在类加载时就初始化实例
  private static instance: Hooker = new Hooker();

  private originalMapConstructor : any;// 原始map构造函数
  private isMapConstructorHooked : boolean = false;

  // 构造函数私有化,防止外部直接实例化
  private constructor() {
    this.sendMapConstructTime()
  }

  // 获取单例实例
  public static getInstance(): Hooker {
    return Hooker.instance;
  }

  //在mapbox.map构造函数埋点,获取开始创建时间及传出map
  public sendMapConstructTime(){
    if (this.isMapConstructorHooked) {
      return;
    }
    this.originalMapConstructor = mapboxgl.Map;
    
    // 重写Map构造函数
    const self = this;
    mapboxgl.Map = function(...args: any[]) {
      // 发送构造开始事件
      window.dispatchEvent(new CustomEvent('mapConstructStart', {
        detail: { timestamp: performance.now() }
      }));
      
      // 调用原始构造函数
      const mapInstance =  new self.originalMapConstructor(...args);

      // 发送构造完成事件,包含map实例
      window.dispatchEvent(new CustomEvent('mapConstructComplete', {
        detail: { map: mapInstance, timestamp: performance.now() }
      }));

      return mapInstance;
    } as any;

     // 复制原型链以确保功能完整
    mapboxgl.Map.prototype = this.originalMapConstructor.prototype;
    
    // 复制静态属性
    Object.keys(this.originalMapConstructor).forEach(key => {
      (mapboxgl.Map as any)[key] = (this.originalMapConstructor as any)[key];
    });
    
    this.isMapConstructorHooked = true;
  }
}

特别注意自定义事件的声明。

之后是PerformanceMonitor,初步设计成这样,后续慢慢改进

import mapboxgl from 'mapbox-gl'
import { Hooker } from '../hooker/hooker'
import { MapConstructorMonitor } from '../metrics/mapConstructorTime';

// 定义事件类型
interface MapConstructCompleteEvent extends CustomEvent {
  detail: {
    map: mapboxgl.Map;
    timestamp: number;
  };
}

interface MonitorConfig{
    enableMonitorMapConstructTime : boolean;
}

const DEFAULT_CONFIG: MonitorConfig = {
    enableMonitorMapConstructTime : true
}


export class PerformanceMonitor {
    private _config : MonitorConfig;
    private _map : mapboxgl.Map| null = null;
    private _hook : Hooker;
    public _mapConstructorMonitor : MapConstructorMonitor | null = null;

    constructor(config?:Partial<MonitorConfig>){
        console.log("PerformanceMonitor创建");

        // 合并默认配置和用户配置
        this._config = { ...DEFAULT_CONFIG, ...config };

        //初始化
        if(this._config.enableMonitorMapConstructTime){
            this._mapConstructorMonitor = new MapConstructorMonitor();
        }

        //创建hook,并
        this._hook = Hooker.getInstance();

        //监听地图创建完成事件,绑定map
        this.setupMapListeners();
    }

    private setupMapListeners() {
        // 监听map构造完成事件
        window.addEventListener('mapConstructComplete', (event: Event) => {
            const customEvent = event as MapConstructCompleteEvent;
            this._map = customEvent.detail.map;
            console.log("PerformanceMonitor获取到mapConstructComplete事件");
        });
    }
}

还是要注意自定义事件的申明和断言,此外就是map的绑定时机,类的创始时间是一定比map构造早的。

之后是MapConstructorMonitor类,注意点同上。

import { Map } from "mapbox-gl";

interface MapConstructStartEvent extends CustomEvent {
  detail: {
    timestamp: number;
  };
}

interface MapConstructCompleteEvent extends CustomEvent {
  detail: {
    map: mapboxgl.Map;
    timestamp: number;
  };
}

export class MapConstructorMonitor{
    private _map : mapboxgl.Map | null = null;
    private mapConsturctTime : number | null = null;
    private mapLoadTIme : number | null = null;
    private mapFisrtIdleTime : number | null = null;

    constructor(){
        this.setupListener();
    }

    private setupListener(){
        // 监听map构造开始事件
        window.addEventListener('mapConstructStart', (event: Event) => {
            const customEvent = event as MapConstructStartEvent;
            this.mapConsturctTime = customEvent.detail.timestamp;
            console.log("MapConstructorMonitor获取到MapConstructStartEvent事件");
        });
        // 监听map构造完成事件
        window.addEventListener('mapConstructComplete', (event: Event) => {
            const customEvent = event as MapConstructCompleteEvent;
            this.mapConsturctTime = customEvent.detail.timestamp;
            this._map = customEvent.detail.map;
            this.setupMapListener();
            console.log("MapConstructorMonitor获取到MapConstructCompleteEvent事件");
        });
    }

    private setupMapListener(){
        // 监听map数据加载完毕事件
        let map = this._map as mapboxgl.Map;
        map.on('load',()=>{
            this.mapLoadTIme = performance.now();
            console.log("MapConstructorMonitor获取到map-load事件");
        })
        map.once('idle',()=>{
            this.mapFisrtIdleTime = performance.now();
            console.log("MapConstructorMonitor获取到map-fisrt-idle事件");
        })
    }
}

最后是index.ts,为了测试方便把performanceMonitor作为window的变量

// 引入必要的模块
import mapboxgl from 'mapbox-gl'
import { PerformanceMonitor } from './core/performanceMonitor';

// 设置Mapbox访问令牌
mapboxgl.accessToken = '';

// 创建性能监测器实例
const _performanceMonitor = new PerformanceMonitor();
(window as any)._performanceMonitor = _performanceMonitor;

// 初始化地图
const map = new mapboxgl.Map({
    container: 'map',
    style: 'mapbox://styles/mapbox/streets-v11',
    center: [116.46, 39.92], // 默认中心点(北京)
    zoom: 10
});

但是现在记录还没规则化存储,于是我们再建一个recorder类

// 开图记录,仅发生一次
export interface MapConstructRecord{
  constructStartTime?: number;
  loadTime?: number;
  firstIdleTime?: number;
}

// 帧记录,高频数据
export interface FrameRecord{
    frameID : number;
    cpuTime : number;
    gpuTime : number;
    timeStamp : number;
}

// fps记录,每秒一次
export interface FpsRecord{
    fps : number;
    timestamp : number;
}

// 用户操作类型
export type UserActionType = 
  | 'zoom' 
  | 'pan' 
  | 'rotate' 
  | 'click' 

// 用户操作记录
export interface UserActionRecord{
    type: UserActionType;
    timestamp: number;
    duration?: number; // 操作持续时间(如拖拽)
    details?: {
        fromZoom?: number;
        toZoom?: number;
        fromCenter?: [number, number];
        toCenter?: [number, number];
        fromBearing?: number;
        toBearing?: number;
        fromPitch?: number;
        toPitch?: number;
        target?: string; // 点击的目标元素
    };
}


// 资源加载记录
export interface ResourceLoadRecord {
  url: string;
  type: 'style' | 'source' | 'tile' | 'image' | 'glyph' | 'sprite';
  startTime: number;
  endTime: number;
  duration: number;
  success: boolean;
  error?: string;
  size?: number; // 资源大小(字节)
}

// 地图状态记录
export interface MapStateRecord {
  timestamp: number;
  zoom: number;
  center: [number, number];
  bearing: number;
  pitch: number;
  bounds?: mapboxgl.LngLatBounds; // 当前视口边界
}

export interface Records{
    mapConstructRecord : MapConstructRecord;
    frameRecords : FrameRecord[];
    fpsRecords: FpsRecord[];
    userActionRecords: UserActionRecord[];
    resourceLoadRecords: ResourceLoadRecord[];
    mapStateRecords: MapStateRecord[];
    metadata?: {
        mapboxVersion?: string;
        browserInfo?: {
            name?: string;
            version?: string;
            userAgent?: string;
        };
        webglInfo?: {
            renderer?: string;
            vendor?: string;
        };
        startTime?: number;
        endTime?: number;
        duration?: number; // 总监测时长
    };
}

// 数据记录类
export class Recorder{
    //饿汉模式
    private static instance: Recorder = new Recorder();

    private records : Records | undefined;

    private constructor(){
        this.reset();
    }

    public static getInstance():Recorder{
        return this.instance;
    }

     // 重置所有记录
    public reset(): void {
        this.records = {
            mapConstructRecord: {},
            frameRecords: [],
            fpsRecords: [],
            userActionRecords: [],
            resourceLoadRecords: [],
            mapStateRecords: [],
            metadata: {
                //mapboxVersion: (mapboxgl as any).version || 'unknown',
                //browserInfo: this.getBrowserInfo(),
                //webglInfo: this.getWebGLInfo(),
                //startTime: Date.now()
            }
        }
    }

    public recordMapConstructTime(mapConstructRecord:MapConstructRecord){
        if (this.records) {
            this.records.mapConstructRecord = mapConstructRecord;
        } 
        else {
            console.error('Records object is not initialized.');
        }
    }
}

这是添加发送记录功能的mapConstructorTime类

import { Map } from "mapbox-gl";
import { MapConstructStartEvent,MapConstructCompleteEvent } from "../hooker/hooker";
import { Recorder } from "../recorder/recorder";

// 开图记录,仅发生一次
export interface MapConstructRecord{
  constructStartTime?: number;
  loadTime?: number;
  firstIdleTime?: number;
}


export class MapConstructorMonitor{
    private map : Map | null = null;
    private mapConstructTime : number | null = null;
    private mapLoadTime : number | null = null;
    private mapFirstIdleTime : number | null = null;
    private recordSent: boolean = false;            // 防止重复发送

    constructor(){
        this.setupListener();
    }

    private setupListener(){
        // 监听map构造开始事件
        window.addEventListener('mapConstructStart', (event: Event) => {
            const customEvent = event as MapConstructStartEvent;
            this.mapConstructTime = customEvent.detail.timestamp;
            // console.log("MapConstructorMonitor获取到MapConstructStartEvent事件");
        });
        // 监听map构造完成事件
        // window.addEventListener('mapConstructComplete', (event: Event) => {
        //     const customEvent = event as MapConstructCompleteEvent;
        //     this._map = customEvent.detail.map;
        //     this.setupMapListener();
        //     console.log("MapConstructorMonitor获取到MapConstructCompleteEvent事件");
        // });
    }

    // 绑定map并开启监听
    public bindMap(map : Map){
      this.map = map;
      this.setupMapListener();
    }

    private setupMapListener(){
      console.log("[mapConstructor] 开始监测开图时间");
      // 监听map数据加载完毕事件
      let map = this.map as Map;
      map.on('load',()=>{
          this.mapLoadTime = performance.now();
          // console.log("MapConstructorMonitor获取到map-load事件");
      })
      map.once('idle',()=>{
          this.mapFirstIdleTime = performance.now();
          // console.log("MapConstructorMonitor获取到map-fisrt-idle事件");
          this.sendMCTRecord();//发送完整的开图记录数据
      })
    }

    private sendMCTRecord(){
      // 防止重复发送记录
      if (this.recordSent) {
          console.warn('Map construct record already sent');
          return;
      }
      // 构造开图记录对象
      const mapConstructRecord: MapConstructRecord = {
          constructStartTime: this.mapConstructTime|| undefined,
          loadTime: this.mapLoadTime || undefined, // 如果为null则设为undefined
          firstIdleTime: this.mapFirstIdleTime || undefined
      };
      try {
        const recorder = Recorder.getInstance();
        recorder.recordMapConstructTime(mapConstructRecord);
        this.recordSent = true; // 标记已发送
        //console.log('Map construct monitoring completete');
      } catch (error) {
        console.error('Failed to send map construct record:', error);
      }  
    }
}

帧指标监测

import { Map } from "mapbox-gl";
import { Recorder } from "../recorder/recorder";
import { BoundedQueue } from "../utils/queue";

export interface MonitorFrameConfig{ //帧性能监测细则
    enableFPS : boolean; //是否开启fps监测
    enableFrameTime : boolean; //是否开启帧耗时监测(cpu+gpu)
    enableLayerTime :boolean; //是否开启每个图层耗时监测
}

export interface Frame{
    frameID : number;
    frameStartTime : number; //触发该帧的时间,帧有可能被丢弃,不一定实际执行绘制
    frameRenderTime : number | null; //开始绘制的时间
    frameCPUTime : number | null; //cpu耗时
    frameGPUTime : number | null; //gpu耗时
    frameLayerTime : {[layerName: string]:number} | null //每个图层的耗时(图层名:时间)
}

export interface FPSRecord{
    index : number;
    startTime : number;
    fps : number;
}

export class FrameMetric{
    private map : Map | null = null;
    private config : MonitorFrameConfig;
    private frameID : number;
    private tempFrameID : BoundedQueue<number>;
    private tempStartTime : BoundedQueue<number>;
    private tempRenderTime : BoundedQueue<number>;
    private tempCpuTime : BoundedQueue<number>;
    private tempGpuTime : BoundedQueue<number>;
    private frames : Frame[];
    private frameAbort : boolean; //帧是否被丢弃,即触发renderstart后未触发render
    private lastSecondTime : number | null = null; //一秒内第一帧的时间,用于计算帧率,初始或idle时设为null
    private lastSecondFrameID : number | null = null; //一秒内第一帧的ID,用于计算帧率
    private FPSRecordIndex : number;

    constructor(config : MonitorFrameConfig){
        this.config = config;
        this.frameID = 0;
        this.tempFrameID = new BoundedQueue<number>(50);
        this.tempStartTime = new BoundedQueue<number>(50);
        this.tempRenderTime = new BoundedQueue<number>(50);
        this.tempCpuTime = new BoundedQueue<number>(50);
        this.tempGpuTime = new BoundedQueue<number>(50);
        this.frames = [];
        this.frameAbort = false;
        this.FPSRecordIndex = 0;
        this.handleRenderStart = this.handleRenderStart.bind(this);
        this.handleRender = this.handleRender.bind(this);
        this.handleGpuTiming = this.handleGpuTiming.bind(this);
        this.handleIdle = this.handleIdle.bind(this);
    }

    public bindMap(map : Map){
        if (this.map !== null) {
            throw new Error("[FrameMetric] bindMap() 已经绑定过 Map 实例。请先调用 unbindMap() 再次绑定。");
        }
        if (!map) {
            throw new Error("[FrameMetric] bindMap() 需要传入有效的 Map 实例。");
        }
        this.map = map;
        this.startMonitor();
        this.lastSecondTime = performance.now();
    }

    public startMonitor(){
        this.resetFrames();
        this.map?.on('renderstart',this.handleRenderStart);
        this.map?.on('render',this.handleRender);
        this.map?.on('idle',this.handleIdle);
        if(this.config.enableFrameTime){
            this.map?.on('gpu-timing-frame', this.handleGpuTiming);//gpu查询会有50ms延迟,注意异步处理
        }
        if(this.config.enableLayerTime){
            //mapbox3.15该事件不能正常查询,2.15版本可以,去实际项目环境中再实现
            //this.map?.on('gpu-timing-layer', this.handleLayerTiming);
        }
        console.log("[FrameMetric] 开始监测帧性能");
    }

    public stopMonitor(){
        if (!this.map) return;
        this.map.off('renderstart', this.handleRenderStart);
        this.map.off('render', this.handleRender);
        this.map.off('idle', this.handleIdle);
        if (this.config.enableFrameTime) {
            this.map.off('gpu-timing-frame', this.handleGpuTiming);
        }
        this.resetFrames();
        this.lastSecondTime = null;
        this.lastSecondFrameID = null;
    }

    private handleRenderStart(){
        //console.log("renderstart!");
        //let _frameId : number = (this.map as Map)._frameId;
        let _frameStartTime : number = performance.now();
        if(this.frameAbort){ // 如果上一帧被丢弃,就只记录帧id和开始时间
            this.frames.push({
                    frameID : this.tempFrameID.dequeue() as number ,
                    frameStartTime : this.tempStartTime.dequeue() as number,
                    frameRenderTime : null,
                    frameCPUTime : null,
                    frameGPUTime : null,
                    frameLayerTime : null           
                }
            );
        }
        this.frameAbort = true;
        this.tempFrameID.enqueue(this.frameID++);
        this.tempStartTime.enqueue(_frameStartTime)
        if(this.lastSecondTime && this.lastSecondFrameID){
            if(_frameStartTime-this.lastSecondTime>=1000){
                if(this.config.enableFPS){
                    this.calFPS();                    
                }
                Recorder.getInstance().recordFrames(this.frames);
                this.resetFrames();
            }            
        }
        else{
            this.lastSecondFrameID = this.frameID;
            this.lastSecondTime = performance.now();
        }
    }

    private handleRender(){
        //console.log("renderFinish!");
        let _frameRenderTime = performance.now();
        this.frameAbort = false;
        if(!this.config.enableFrameTime && !this.config.enableLayerTime){
            //如果不检测cpu、gpu时间及图层渲染时间,就直接加入记录
            this.frames.push({
                    frameID : this.tempFrameID.dequeue() as number ,
                    frameStartTime : this.tempStartTime.dequeue() as number,
                    frameRenderTime : _frameRenderTime,
                    frameCPUTime : null,
                    frameGPUTime : null,
                    frameLayerTime : null     
                }
            );
        }
        //否则加入队列
        else{
            this.tempRenderTime.enqueue(_frameRenderTime);
        }
    }

    private handleIdle(){
        //触发Idle时进行帧率计算以处理特殊情况
        if(this.config.enableFPS){
           this.calFPSWithIdle();            
        }
        Recorder.getInstance().recordFrames(this.frames);
        this.resetFrames();
    }

    private handleGpuTiming(e:{cpuTime:number; gpuTime:number}){
        //console.log("timeCalFinish!");
        //if(this.config.enableLayerTime){ //如果还要统计各图层渲染时间,就加入队列
        if(this.config.enableLayerTime && false){ //现在还没支持图层时间统计,先强制跳过
            this.tempCpuTime.enqueue(e.cpuTime);
            this.tempGpuTime.enqueue(e.gpuTime);
        }
        else{
            this.frames.push({
                    frameID : this.tempFrameID.dequeue() as number ,
                    frameStartTime : this.tempStartTime.dequeue() as number,
                    frameRenderTime : this.tempRenderTime.dequeue() as number,
                    frameCPUTime : e.cpuTime,
                    frameGPUTime : e.gpuTime,
                    frameLayerTime : null     
                }
            );
        }
    }

    private calFPS(){
        let _fps = this.frameID - (this.lastSecondFrameID as number) - 1;
        //console.log("fps:",_fps);
        let _fpsRecord = {
            index : this.FPSRecordIndex++,
            startTime : this.lastSecondTime as number,
            fps : _fps 
        };
        Recorder.getInstance().recordFPS(_fpsRecord);
        this.lastSecondFrameID = this.frameID;
        this.lastSecondTime = performance.now();
    }

    private calFPSWithIdle(){
        let _fps = Math.round((this.frameID - (this.lastSecondFrameID as number))
                                /(performance.now() - (this.lastSecondTime as number)) * 1000);//触发idle时不足1秒
        if(_fps == 0){//如果触发idle时刚好计算完一次帧率,一个新帧都没有,就跳过记录
            this.lastSecondFrameID = null;
            this.lastSecondTime = null;
            return;
        }
        //console.log("fps:",_fps)
        let _fpsRecord = {
            index : this.FPSRecordIndex++,
            startTime : this.lastSecondTime as number,
            fps : _fps 
        };
        Recorder.getInstance().recordFPS(_fpsRecord);
        this.lastSecondFrameID = null;
        this.lastSecondTime = null;
    }


    private resetFrames(){
        this.frames = [];
    }
}

用户输入监测

import { Map } from "mapbox-gl";
import { Recorder } from "../recorder/recorder";

export type UserActionType = 
  | 'zoom' 
  | 'drag' 
  | 'rotate' 
  | 'pitch'

  // 用户操作记录
export interface UserActionRecord{
    type: UserActionType;
    startTime: number;
    endTime : number;
    details?: {
        fromZoom?: number;
        toZoom?: number;
        fromCenter?: [number, number];
        toCenter?: [number, number];
        fromBearing?: number;
        toBearing?: number;
        fromPitch?: number;
        toPitch?: number;
    };
}

interface ActionBase{
    startTime: number;
    endTime : number;
}

interface DragAction{
    base : ActionBase;
    fromCenter : [number,number];
    toCenter : [number,number];
}

interface ZoomAction{
    base : ActionBase;
    fromZoom : number;
    toZoom : number;
}

interface RotateAction{
    base : ActionBase;
    fromBearing : number;
    toBearing : number;
}

interface PitchAction{
    base : ActionBase;
    fromPitch : number;
    toPitch : number;
}

interface CurrentActions{
    drag : DragAction | null;
    zoom : ZoomAction | null;
    rotate : RotateAction | null;
    pitch : PitchAction | null;
}

export class UserActionMetric{
    private map: Map | null = null;
    private currentActions : CurrentActions;
    private isMonitoring : boolean;

    constructor(){
        this.currentActions = {
            drag : null,
            zoom : null,
            rotate : null,
            pitch : null
        }
        this.isMonitoring = false;
        
        this.handleDragStart = this.handleDragStart.bind(this);
        this.handleDragMove = this.handleDragMove.bind(this);
        this.handleDragEnd = this.handleDragEnd.bind(this);
        this.handleZoomStart = this.handleZoomStart.bind(this);
        this.handleZoomMove = this.handleZoomMove.bind(this);
        this.handleZoomEnd = this.handleZoomEnd.bind(this);        
        this.handleRotateStart = this.handleRotateStart.bind(this);
        this.handleRotateMove = this.handleRotateMove.bind(this);
        this.handleRotateEnd = this.handleRotateEnd.bind(this);
        this.handlePitchStart = this.handlePitchStart.bind(this);
        this.handlePitchMove = this.handlePitchMove.bind(this);
        this.handlePitchEnd = this.handlePitchEnd.bind(this);
    }

    public bindMap(map : Map){
        if (this.map !== null) {
            throw new Error("[UserActionMetric] bindMap() 已经绑定过 Map 实例。");
        }
        if (!map) {
            throw new Error("[UserActionMetric] bindMap() 需要传入有效的 Map 实例。");
        }
        this.map = map;
        this.startMonitor();
    }

    public startMonitor(){
        if(!this.map)
            throw new Error("[UserActionMetric] startMonitor() 未绑定有效map对象。")
        if(this.isMonitoring){
            console.log("用户操作已在记录,请勿多次启动");
            return;
        }
        // 注册所有事件监听
        this.map.on('dragstart', this.handleDragStart);
        //this.map.on('drag', this.handleDragMove);
        this.map.on('dragend', this.handleDragEnd);
        
        this.map.on('zoomstart', this.handleZoomStart);
        //this.map.on('zoom', this.handleZoomMove);
        this.map.on('zoomend', this.handleZoomEnd);
        
        this.map.on('rotatestart', this.handleRotateStart);
        //this.map.on('rotate', this.handleRotateMove);
        this.map.on('rotateend', this.handleRotateEnd);
        
        this.map.on('pitchstart', this.handlePitchStart);
        //this.map.on('pitch', this.handlePitchMove);
        this.map.on('pitchend', this.handlePitchEnd);
        this.isMonitoring = true;

        console.log("[UserActionMetric] 开始监控用户操作");
    }

    public stopMonitor(){
        if(!this.map)
            throw new Error("[UserActionMetric] stopMonitor() 未绑定有效map对象。")
        if(!this.isMonitoring){
            console.log("用户操作未在记录,无法关闭");
            return;
        }
        this.map.off('dragstart', this.handleDragStart);
        //this.map.off('drag', this.handleDragMove);
        this.map.off('dragend', this.handleDragEnd);
        
        this.map.off('zoomstart', this.handleZoomStart);
        //this.map.off('zoom', this.handleZoomMove);
        this.map.off('zoomend', this.handleZoomEnd);
        
        this.map.off('rotatestart', this.handleRotateStart);
        //this.map.off('rotate', this.handleRotateMove);
        this.map.off('rotateend', this.handleRotateEnd);
        
        this.map.off('pitchstart', this.handlePitchStart);
        //this.map.off('pitch', this.handlePitchMove);
        this.map.off('pitchend', this.handlePitchEnd);

        this.isMonitoring = false;
    }

    private handleDragStart(){
        // 改为警告而不是抛出错误,避免中断应用
        if (this.currentActions.drag) {
            console.warn("[UserActionMetric] 有未结束的Drag操作,将覆盖之前的操作");
            this.currentActions.drag = null;
        }
        const center = this.map?.getCenter();
        if (!center) {
            throw new Error("无法获取地图中心点");
        }
        const centerArray: [number, number] = [center.lng, center.lat];
        this.currentActions.drag = {
            base: {
                startTime: performance.now(),
                endTime: 0   // 先设置为0,结束的时候再设置
            },
            fromCenter: centerArray,
            toCenter: centerArray // 初始化为相同值,在dragEnd时更新
        };
    }
    private handleDragMove(){
        //先不记东西
    }
    private handleDragEnd(){
        if(!this.currentActions.drag){
            throw new Error("[UserActionMetric] handleDragStart() 不存在已开始的Drag操作。")
        }
        const center = this.map?.getCenter();
        if (!center) {
            throw new Error("无法获取地图中心点");
        }
        const centerArray: [number, number] = [center.lng, center.lat];
        this.currentActions.drag.toCenter = centerArray;
        this.currentActions.drag.base.endTime = performance.now();
        // 记录至recorder
        let dragAction : UserActionRecord = {
            type: 'drag',
            startTime: this.currentActions.drag.base.startTime,
            endTime : this.currentActions.drag.base.endTime,
            details: {
                fromCenter: this.currentActions.drag.fromCenter,
                toCenter: this.currentActions.drag.toCenter,
            }
        }
        Recorder.getInstance().recordUserAction(dragAction);
        this.currentActions.drag = null;
    }

    private handleZoomStart() {
        if (this.currentActions.zoom) {
            console.warn("[UserActionMetric] 有未结束的Zoom操作,将覆盖之前的操作");
            this.currentActions.zoom = null;
        }
        
        const zoom = this.map?.getZoom();
        if (zoom === undefined) {
            console.error("无法获取地图缩放级别");
            return;
        }
        
        this.currentActions.zoom = {
            base: {
                startTime: performance.now(),
                endTime: 0
            },
            fromZoom: zoom,
            toZoom: zoom
        };
    }

    private handleZoomMove(){

    }

    private handleZoomEnd() {
        if (!this.currentActions.zoom) {
            console.warn("[UserActionMetric] 不存在已开始的Zoom操作");
            return;
        }
        
        const zoom = this.map?.getZoom();
        if (zoom === undefined) {
            console.error("无法获取地图缩放级别");
            this.currentActions.zoom = null;
            return;
        }
        
        this.currentActions.zoom.toZoom = zoom;
        this.currentActions.zoom.base.endTime = performance.now();
        
        const zoomAction: UserActionRecord = {
            type: 'zoom',
            startTime: this.currentActions.zoom.base.startTime,
            endTime: this.currentActions.zoom.base.endTime,
            details: {
                fromZoom: this.currentActions.zoom.fromZoom,
                toZoom: this.currentActions.zoom.toZoom
            }
        };
        
        Recorder.getInstance().recordUserAction(zoomAction);
        this.currentActions.zoom = null;
    }

    private handleRotateStart() {
        if (this.currentActions.rotate) {
            console.warn("[UserActionMetric] 有未结束的Rotate操作,将覆盖之前的操作");
            this.currentActions.rotate = null;
        }
        
        const bearing = this.map?.getBearing();
        if (bearing === undefined) {
            console.error("无法获取地图方位角");
            return;
        }
        
        this.currentActions.rotate = {
            base: {
                startTime: performance.now(),
                endTime: 0
            },
            fromBearing: bearing,
            toBearing: bearing
        };
    }

    private handleRotateMove(){

    }

    private handleRotateEnd() {
        if (!this.currentActions.rotate) {
            console.warn("[UserActionMetric] 不存在已开始的Rotate操作");
            return;
        }
        
        const bearing = this.map?.getBearing();
        if (bearing === undefined) {
            console.error("无法获取地图方位角");
            this.currentActions.rotate = null;
            return;
        }
        
        this.currentActions.rotate.toBearing = bearing;
        this.currentActions.rotate.base.endTime = performance.now();
        
        const rotateAction: UserActionRecord = {
            type: 'rotate',
            startTime: this.currentActions.rotate.base.startTime,
            endTime: this.currentActions.rotate.base.endTime,
            details: {
                fromBearing: this.currentActions.rotate.fromBearing,
                toBearing: this.currentActions.rotate.toBearing
            }
        };
        
        Recorder.getInstance().recordUserAction(rotateAction);
        this.currentActions.rotate = null;
    }


    private handlePitchStart() {
        if (this.currentActions.pitch) {
            console.warn("[UserActionMetric] 有未结束的Pitch操作,将覆盖之前的操作");
            this.currentActions.pitch = null;
        }
        
        const pitch = this.map?.getPitch();
        if (pitch === undefined) {
            console.error("无法获取地图倾斜角度");
            return;
        }
        
        this.currentActions.pitch = {
            base: {
                startTime: performance.now(),
                endTime: 0
            },
            fromPitch: pitch,
            toPitch: pitch
        };
    }

    private handlePitchMove(){

    }

    private handlePitchEnd() {
        if (!this.currentActions.pitch) {
            console.warn("[UserActionMetric] 不存在已开始的Pitch操作");
            return;
        }
        
        const pitch = this.map?.getPitch();
        if (pitch === undefined) {
            console.error("无法获取地图倾斜角度");
            this.currentActions.pitch = null;
            return;
        }
        
        this.currentActions.pitch.toPitch = pitch;
        this.currentActions.pitch.base.endTime = performance.now();
        
        const pitchAction: UserActionRecord = {
            type: 'pitch',
            startTime: this.currentActions.pitch.base.startTime,
            endTime: this.currentActions.pitch.base.endTime,
            details: {
                fromPitch: this.currentActions.pitch.fromPitch,
                toPitch: this.currentActions.pitch.toPitch
            }
        };
        
        Recorder.getInstance().recordUserAction(pitchAction);
        this.currentActions.pitch = null;
    }

    public getMonitoringStatus(): boolean {
        return this.isMonitoring;
    }

    public resetCurrentActions(): void {
        this.currentActions = {
            drag: null,
            zoom: null,
            rotate: null,
            pitch: null
        };
    }
}

用户输入模拟

import { Map } from "mapbox-gl";

export type SimulateInput =   
  | 'zoom' 
  | 'drag' 
  | 'rotate' 
  | 'pitch'

export interface SimulateActionConfig{ //帧性能监测细则
    actionSequenceID : number; //执行队列ID,根据ID请求不同的模拟操作数组
    delayTime : number; //延迟时间,在地图idle事件后多久开始执行下一个动作
    durationTime : number; //默认动作的持续时间
}

export interface SimulateAction{
    type : SimulateInput;
    wheelAmount? : number; //zoom模拟,滚轮的转动量
    centerPosition? : [number,number]; //zoom模拟,缩放的中心点
    startPosition? : [number,number]; //drag、rotate、pitch通用,相对位置    
    endPosition? : [number,number]; //drag、rotate、pitch通用,相对位置
    duration? : number; //动作持续的时间(ms)
}

export class ActionSimulator{
    private map : Map | null = null;
    private config : SimulateActionConfig;
    private currentActionIndex : number; //当前模拟操作的序号
    private actionSequence : SimulateAction[];//模拟操作序列
    private actionSequenceNum : number;//模拟操作总数
    private delayTime : number;//延迟时间
    private maxFPS : number;//最大帧率,与模拟操作的时间控制有关。
    private perFlameTime : number;//每一帧理论间隔(ms)
    private durationTime : number;//默认动作持续的时间(ms)

    constructor(config:SimulateActionConfig , maxFPS : number){
        this.config = config;
        this.currentActionIndex = 0;
        this.actionSequence = this.requestActions(this.config.actionSequenceID);
        this.actionSequenceNum = this.actionSequence.length;
        this.delayTime = this.config.delayTime;
        this.executeNextAction = this.executeNextAction.bind(this);
        this.executeAction = this.executeAction.bind(this);
        this.maxFPS = maxFPS;
        this.perFlameTime = 1000 / this.maxFPS;
        this.durationTime = config.durationTime;
    }
    
    // 请求模拟操作队列,目前先模拟
    private requestActions(actionSequenceID:number):SimulateAction[]{
        return actionSequence;
    }

    public bindMap(map:Map){
        if (this.map !== null) {
            throw new Error("[actionSimulator] bindMap() 已经绑定过 Map 实例。");
        }
        if (!map) {
            throw new Error("[actionSimulator] bindMap() 需要传入有效的 Map 实例。");
        }
        this.map = map;
        this.startSimulate();
    }

    public startSimulate(){
        console.log("[ActionSimulator] 开始模拟用户操作");
        if(!this.map)
            throw new Error("[actionSimulator] startSimulate() 无有效的 Map 实例。");
        this.map.on('idle',this.executeNextAction);
    }

    private executeNextAction(){
        //console.log("idle,excute next action");
        setTimeout(() => {
            if(this.currentActionIndex>=this.actionSequenceNum){
                this.map?.off('idle',this.executeNextAction);
                return;
            }
            this.executeAction(this.currentActionIndex++);
        }, this.delayTime);
    }

    private executeAction(actionIndex : number){
        let targetAction = this.actionSequence[actionIndex];
        switch (targetAction.type) {
            case 'zoom':
            this.executeZoom(targetAction);
            break;
            case 'drag':
            this.executeDrag(targetAction);
            break;
            case 'rotate':
            this.executeRotate(targetAction);
            break;
            case 'pitch':
            this.executePitch(targetAction);
            break;
            default:
            // 处理未知类型或提供类型保护
            const exhaustiveCheck: never = targetAction.type;
            throw new Error(`未知的操作类型: ${targetAction.type}`);
        }
    }

    private executeZoom(action:SimulateAction){
        if (!this.map || action.wheelAmount === undefined) return;
        const canvas = this.map.getCanvas();
        const durationTime = action.duration ? action.duration : this.durationTime;
        const totalSteps = durationTime / this.perFlameTime;
        // 获取画布相对于视口的位置和尺寸
        const rect = canvas.getBoundingClientRect();
        // 计算画布中心点坐标(相对于视口)
        const clientX = rect.left + rect.width * (action.centerPosition?action.centerPosition?.[0] : 0.5);
        const clientY = rect.top + rect.height * (action.centerPosition?action.centerPosition?.[1] : 0.5);
        let step = 0;
        const wheelAmountStep = action.wheelAmount / totalSteps;

        const interval = setInterval(() => {
            if (step < totalSteps) {
                const wheelEvent = new WheelEvent('wheel', {
                    bubbles: true,
                    cancelable: true,
                    deltaY: wheelAmountStep,
                    clientX: clientX,
                    clientY: clientY,
                    deltaMode: WheelEvent.DOM_DELTA_PIXEL,
                });
                canvas.dispatchEvent(wheelEvent);
                step++;
            } else {
                clearInterval(interval); // 结束时清除interval
            }
        }, this.perFlameTime);
    }

    private executeDrag(action:SimulateAction){
        if (!this.map || action.startPosition === undefined || action.endPosition === undefined) return;
        const canvas = this.map.getCanvas();
        const durationTime = action.duration ? action.duration : this.durationTime;
        const totalSteps = durationTime / this.perFlameTime;
        // 获取画布相对于视口的位置和尺寸
        const rect = canvas.getBoundingClientRect();
        // 计算画布起始点坐标(相对于视口)
        const startX = rect.left + rect.width * (action.startPosition?action.startPosition?.[0] : 0.5);
        const startY = rect.top + rect.height * (action.startPosition?action.startPosition?.[1] : 0.5);
        const endX = rect.left + rect.width * (action.endPosition?action.endPosition?.[0] : 0.5);
        const endY = rect.top + rect.height * (action.endPosition?action.endPosition?.[1] : 0.5);

        let step = 0;
        // 1. 鼠标按下
        const mousedown = new MouseEvent('mousedown', {
            bubbles: true,
            cancelable: true,
            clientX: startX,
            clientY: startY,
            button: 0,
            buttons: 1
        });
        canvas.dispatchEvent(mousedown);

        // 2. 拖拽移动过程
        const interval = setInterval(() => {
            step++;
            if (step <=totalSteps) {
                const progress = step / totalSteps;
                const currentX = startX + (endX - startX) * progress;
                const currentY = startY + (endY - startY) * progress;
                const mouseMoveEvent = new MouseEvent('mousemove', {
                    bubbles: true,
                    clientX: currentX,
                    clientY: currentY,
                    button: 0,
                    buttons: 1
                });
                canvas.dispatchEvent(mouseMoveEvent);
            } else {
                // 3. 鼠标释放
                const mouseup = new MouseEvent('mouseup', {
                    bubbles: true,
                    clientX: endX,
                    clientY: endY,
                    button: 0,
                    buttons: 0
                });
                canvas.dispatchEvent(mouseup);
                //console.log("松开鼠标左键");
                clearInterval(interval); // 结束时清除interval
            }
        }, this.perFlameTime);
    }
        
    private executeRotate(action:SimulateAction){
        if (!this.map || action.startPosition === undefined || action.endPosition === undefined) return;
        const canvas = this.map.getCanvas();
        const durationTime = action.duration ? action.duration : this.durationTime;
        const totalSteps = durationTime / this.perFlameTime;
        // 获取画布相对于视口的位置和尺寸
        const rect = canvas.getBoundingClientRect();
        // 计算画布起始点坐标(相对于视口)
        const startX = rect.left + rect.width * (action.startPosition?action.startPosition?.[0] : 0.5);
        const endX = rect.left + rect.width * (action.endPosition?action.endPosition?.[0] : 0.5);

        let step = 0;
        // 1. 鼠标按下
        const mousedown = new MouseEvent('mousedown', {
            bubbles: true,
            cancelable: true,
            clientX: startX,
            clientY: rect.height/2, //旋转,屏蔽y轴
            button: 2,
            buttons: 2
        });
        canvas.dispatchEvent(mousedown);

        // 2. 拖拽移动过程
        const interval = setInterval(() => {
            step++;
            if (step <=totalSteps) {
                const progress = step / totalSteps;
                const currentX = startX + (endX - startX) * progress;
                const currentY = rect.height/2; //旋转,屏蔽y轴
                const mouseMoveEvent = new MouseEvent('mousemove', {
                    bubbles: true,
                    clientX: currentX,
                    clientY: currentY,
                    button: 2,
                    buttons: 2
                });
                canvas.dispatchEvent(mouseMoveEvent);
            } else {
                // 3. 鼠标释放
                const mouseup = new MouseEvent('mouseup', {
                    bubbles: true,
                    clientX: endX,
                    clientY: rect.height/2, //旋转,屏蔽y轴
                    button: 0,
                    buttons: 0
                });
                //console.log("松开鼠标右键");
                canvas.dispatchEvent(mouseup);
                //晃动一下,防止不触发idle
                const mouseMoveEvent = new MouseEvent('mousemove', {
                    bubbles: true,
                    clientX: 0,
                    clientY: 0,
                    button: 0,
                    buttons: 0
                });
                canvas.dispatchEvent(mouseMoveEvent);
                clearInterval(interval); // 结束时清除interval
            }
        }, this.perFlameTime);
    }

    private executePitch(action:SimulateAction){
        if (!this.map || action.startPosition === undefined || action.endPosition === undefined) return;
        const canvas = this.map.getCanvas();
        const durationTime = action.duration ? action.duration : this.durationTime;
        const totalSteps = durationTime / this.perFlameTime;
        // 获取画布相对于视口的位置和尺寸
        const rect = canvas.getBoundingClientRect();
        // 计算画布起始点坐标(相对于视口)
        const startY = rect.top + rect.height * (action.startPosition?action.startPosition?.[1] : 0.5);
        const endY = rect.top + rect.height * (action.endPosition?action.endPosition?.[1] : 0.5);

        let step = 0;
        // 1. 鼠标按下
        const mousedown = new MouseEvent('mousedown', {
            bubbles: true,
            cancelable: true,
            clientX: rect.width/2, // 俯仰,屏蔽x轴
            clientY: startY,
            button: 2,
            buttons: 2
        });
        canvas.dispatchEvent(mousedown);

        // 2. 拖拽移动过程
        const interval = setInterval(() => {
            step++;
            if (step <=totalSteps) {
                const progress = step / totalSteps;
                const currentX = rect.width/2; // 俯仰,屏蔽x轴
                const currentY = startY + (endY - startY) * progress;
                const mouseMoveEvent = new MouseEvent('mousemove', {
                    bubbles: true,
                    clientX: currentX,
                    clientY: currentY,
                    button: 2,
                    buttons: 2
                });
                canvas.dispatchEvent(mouseMoveEvent);
            } else {
                // 3. 鼠标释放
                const mouseup = new MouseEvent('mouseup', {
                    bubbles: true,
                    clientX: rect.width/2, // 俯仰,屏蔽x轴
                    clientY: endY, //旋转,屏蔽y轴
                    button: 0,
                    buttons: 0
                });
                //console.log("松开鼠标右键");
                //晃动一下,防止不触发idle
                canvas.dispatchEvent(mouseup);
                const mouseMoveEvent = new MouseEvent('mousemove', {
                    bubbles: true,
                    clientX: 0,
                    clientY: 0,
                    button: 0,
                    buttons: 0
                });
                canvas.dispatchEvent(mouseMoveEvent);
                clearInterval(interval); // 结束时清除interval
            }
        }, this.perFlameTime);
    }
}

const actionSequence : SimulateAction[] = [
    {
        type : "zoom",
        wheelAmount : -200, 
        centerPosition: [0.5,0.5],
        duration : 2000,
    },
    {
        type : "drag",
        startPosition : [0.2,0.2],
        endPosition : [0.8,0.8],
        duration : 2000,
    },
    {
        type : "drag",
        startPosition : [0.5,0.5],
        endPosition : [0.3,0.6],
        duration : 2000,
    },
    {
        type : "rotate",
        startPosition : [0.5,0.5],
        endPosition : [0.6,0.5],
        duration : 2000,
    },
    {
        type : "pitch",
        startPosition : [0.5,0.5],
        endPosition : [0.5,0.4],
        duration : 2000,
    },
        {
        type : "rotate",
        startPosition : [0.5,0.5],
        endPosition : [0.6,0.5],
        duration : 2000,
    },
    {
        type : "pitch",
        startPosition : [0.5,0.5],
        endPosition : [0.5,0.4],
        duration : 2000,
    },
]

地图状态监控

届ける言葉を今は育ててる
最后更新于 2025-10-09