顺着上一篇设计思路,开始做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,
},
]











Comments 1 条评论
博主 匿名
看不懂,可以线下讲解么OvO