eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
wL7ARAXUnUQwUJCw9Hem0dSfB0ZCdAyBi8-bOjBcgiI
.
为分割符,按顺序排列{
"alg": "HS256",
"typ": "JWT"
}
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
alg
⇒ Signature or encryption algorithm,即签名算法。常用算法为:HMAC SHA256、RSAtyp
⇒ token 类型,固定为 JWT{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
sub
⇒ Subject (whom the token refers to),即 token 指向。一般设置为用户 IDname
⇒ Name,即用户名iat
⇒ Issued at (Time that the JWT was created),即创建时间exp
⇒ Expires at (The expiration time of the JWT),过期时间对 Header 以及 Payload 进行签名保证 Token 在传输过程中没有被篡改和损坏
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
localstorage
每次请求将其放入请求头字段 Authorization
中Bearer
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.wL7ARAXUnUQwUJCw9Hem0dSfB0ZCdAyBi8-bOjBcgiI
Authentication
调用验证方法对 JWT 进行校验sid
(session id)那样手动清除后重新生成,必须需要等到 JWT 过期后重新生成。目前常见做法是在判断 token 过期时读 token 记录,如果被删除那么重新登陆。access_token
& refresh_token
流程token
数据的情况,常见做法是在判断 token
过期时读 token
记录,如果被删除那么重新登陆。基于这个思路那么建议如下:token
记录存储在内存数据库中(redis)要好于存储在数据库中access_token
Representational State Transfer 数据表现形式地传输
https://api.github.com/users
https://api.github.com/users/i7eo
/users/i7eo/repositories/eo-imooc/blob/main
其中 /users/i7eo
为一层表述,依此类推。a
标签https://api.github.com/users?since=100
https://api.github.com/users?page=2&per_page=50
https://api.github.com/users/i7eo?fields=name
function sleep(delay) {
return new Promise((resolve) => {
setTimeout(resolve, delay)
})
}
async function taskScheduler(_tasks = [], delay = 0) {
if (!_tasks.length) return
const tasks = _tasks.filter((task) => typeof task === 'function')
let deadline = performance.now() + 50
while (tasks.length > 0) {
// Optional chaining operator used here helps to avoid
// errors in browsers that don't support `isInputPending`:
if (
navigator.scheduling?.isInputPending() ||
performance.now() >= deadline
) {
// There's a pending user input, or the
// deadline has been reached. Yield here:
await sleep(delay)
// Extend the deadline:
deadline = performance.now() + 50
// Stop the execution of the current loop and
// move onto the next iteration:
continue
}
// Shift the task out of the queue:
const task = tasks.shift()
// Run the task:
await task()
}
}
requestAnimationFrame
进行拆分window.innerWidth
等,可以在进入页面后配合上述 taskScheduler
获取后挂载在 window
上后续监听窗口变化再更新即可content_for_header
代码拆分进入 Service Worker 中执行fetch
动态获取内容将其显示fetch
动态获取内容将其显示,从而达到减轻 liquid/html 体积的目的launch.json
常用变量require
的使用场景require('fs')
require('lodash')
require('./utils')
process.dlopen(module, path.toNamespacedPath(filename))
处理).node
文件,其余文件均使用 fs.readFileSync
读取文件内容然后构造可执行的 compiledWrapper
函数处理。require
执行流程分析require module
对象属性记录id
: 源码文件路径,如:/Users/i7eo/Documents/Workspace/GitHub/eopol/cli-helper-test/src/require-orignal.js
根 Module
id
为 '.'path
: 源码文件所在的文件夹,通过 path.dirname(id)
生成,例如:/Users/i7eo/Documents/Workspace/GitHub/eopol/cli-helper-test/src
根据这个向上递归生成一级一级的目录(paths)用来寻找 node_modules
最上层为 /node_modules
exports
: 模块输出的内容,默认为 {}
parent
: 父模块信息filename
: 源码文件路径loaded
: 是否已经加载完毕children
: 子模块对象集合paths
: 模块查询范围require('internal/modules/cjs/loader').Module.runMain(process.argv[1]);
加载主流程,runMain
中判断当前是 cjs 还是 esmvalidateString(id, 'id');
Module._load(process.argv[1], null, true)
加载当前文件(主模块,第二个参数 parent 为 null,第三个参数 ismain 为 true)Module._resolveFilename
根据 paths、ext(.js/.json/…)、packageManager 拼出绝对路径Module._cache[filename]
查看是否有缓存,如果有即刻返回loadBuiltinModule
带着上面拼出的绝对路径去内置库找(BuiltinModule.map
存储所有内置模块,已 _ 开头)如果有即刻返回module.load
加载具体文件findLongestRegisteredExtension
找到当前文件的解析器(Module._extensions['.js']
/Module._extensions['.node']
/Module._extensions['.json']
),并调用读取内容(fs.readFileSync
)以及生成可以执行函数(module._compile
其中调用 compileFunction
将读取的内容以及 exports, require, module, filename, dirname 传入,生成一个可执行函数 compiledWrapper
)_compile
方法底部调用 compiledWrapper
并将结果返回node ./src/require-orignal.js
文件内容前主流程的过程。上述过程会先于文件内容执行,文件内容中的 require 断点直接进入下面 12require('xxx')
会直接进入Module.prototype.require
,该方法加载传入的指定路径文件(ismain 为 false),其中利用 requireDepth
加减来控制是否加载完毕,文件中的依赖也是通过调用Module.prototype.require
加载。调用 Module._load
重复上述 3-11 的过程require
连续加载同一个模块时,是如何进行缓存的?module
的 path
+ \x00
文件名对应的绝对路径+文件名.后缀Module
对象path.dirname(filename)
获取文件路径path.basename(filename)
获取文件名(带后缀)require
requireDepth
加一,执行完require
进来的模块后,requireDepth
减一Module._resolveFilename()
调用 Module._findPath()
之后,在返回的文件名不存在,没有模块,报错Module._nodeModulePaths(path.dirname(filename))
产生的 node_modules
数组,最终保存在了 Module
对象上compileFunction
方法来源模块是不一样的exec
和 execFile
到底有什么区别exec
/ execFile
/ fork
都是通过 spawn
实现的,spawn
的作用到底是什么?spawn
调用后没有回调,而 exec
/ execFile
能够回调?spawn
调用后需要手动调用 child.stdout.on('data', callback)
,这里的 child.stdio
/ child.stderr
到底是什么?data
/ error
/ exit
/ close
这么多种回调,它们的执行顺序到底是什么怎样的?ChildProcess
来自const child_process = require('internal/child_process');
ChildProcess
中使用 Process
创建进程并且给实例_handle
挂载监听函数 onexit
(即监听进程代码执行结束时机)Process
来自const { Process } = internalBinding('process_wrap');
(node 源码 src/process_wrap.cc)execFile
函数最底部onexit
被 c++ 触发,执行 this._handle.close()
结束通信(c++处理)。之后在onexit
底部调用 maybeClose(subprocess)
,maybeClose
中执行 subprocess.emit('close', subprocess.exitCode, subprocess.signalCode)
通知 execFile
底部监听的 close 事件execFile
中监听的 close 事件处理函数 exithandler
中,将 stdin/stdout/stderr
封装好调用传入的 callback 返回_handler.pid
或者pid
看到。shell
的使用/bin/sh test.shell
shell
语句(类似 node -e "console.log(123)"
)/bin/sh -c "ls -la"
-c
参数就要指定文件(输入文件路径)shell
命令 ls -la
=== /bin/sh -c "ls -la"
(这一步是操作系统帮忙做的,一般简写即可)exec
源码精读// 等同于 {...Object(true)}
{...true} // {}
// 等同于 {...Object(undefined)}
{...undefined} // {}
// 等同于 {...Object(null)}
{...null} // {}
{...'hello'}
// {0: "h", 1: "e", 2: "l", 3: "l", 4: "o"}
{ ...['a', 'b', 'c'] };
// {0: "a", 1: "b", 2: "c"}
function
类型,都会产生获得一个对象。function normalizeExecArgs(command, options, callback) {
if (typeof options === 'function') {
callback = options;
options = undefined;
}
// 浅拷贝
options = { ...options }; // 将任意非 function 都将转化为参数
options.shell = typeof options.shell === 'string' ? options.shell : true; // 得到shell属性。
return {
file: command,
options: options, // options 至少有一个属性:shell
callback: callback
};
}
option.shell
可以是一个字符串,用来执行命令的文件。默认值: Unix 上是 '/bin/sh'
,Windows 上是 process.env.ComSpec
execFile
中首先对参数逐个判断,判断逻辑有点意思function execFile(file /* , args, options, callback */) {
let args = [];
let callback;
let options;
// 解析可选参数(第一个参数是 shell 文件路径),使用argument
let pos = 1;
if (pos < arguments.length && Array.isArray(arguments[pos])) { // 获得传入shell文件的参数
args = arguments[pos++];
} else if (pos < arguments.length && arguments[pos] == null) { // 第二个参数给 null 跳过第二个参数解析
pos++;
}
if (pos < arguments.length && typeof arguments[pos] === 'object') { // 参数是 Object 类型,认为是options
options = arguments[pos++];
} else if (pos < arguments.length && arguments[pos] == null) { // 参数值是 null,跳过
pos++;
}
if (pos < arguments.length && typeof arguments[pos] === 'function') { // 获得回调函数
callback = arguments[pos++];
}
if (!callback && pos < arguments.length && arguments[pos] != null) { // 经过以上步骤,传参了但没有解析到回调函数,报错。
throw new ERR_INVALID_ARG_VALUE('args', arguments[pos]);
}
...
}
//args = args.slice(0)
var a = [1, 2, 3];
var b = a.slice(0); // b: [1, 2, 3] => b = [...a]
a === b; // false
spawn
中的命令拼接部分if (options.shell) {
const command = [file].concat(args).join(' '); // 拼接命令文件和传入的参数
// Set the shell, switches, and commands.
if (process.platform === 'win32') { // windows
if (typeof options.shell === 'string') // 自定义执行shell的文件
file = options.shell;
else
file = process.env.comspec || 'cmd.exe';
// '/d /s /c' is used only for cmd.exe.
if (/^(?:.*\\)?cmd(?:\.exe)?$/i.test(file)) { // 匹配任意路径下的 cmd.exe。这里指定了 cmd.exe 的路径
args = ['/d', '/s', '/c', `"${command}"`]; // '/d /s /c' 仅用于 cmd.exe.
options.windowsVerbatimArguments = true; // options 中的 windowsVerbatimArguments 参数
} else {
args = ['-c', command];
}
} else {
if (typeof options.shell === 'string')
file = options.shell;
else if (process.platform === 'android') // 安卓系统
file = '/system/bin/sh';
else
file = '/bin/sh'; // 默认使用 '/bin/sh'
args = ['-c', command];
}
}
spawn
中的 new ChildProcess()
EventEmitter.call(this);
之后,可以分发事件了。emit
分发on
监听this._handle.onexit
进程执行完之后回调child.spawn
/ ChildProcess.prototype.spawn
getValidStdio()
创建输入/输出/错误流(处理函数挂在 handle 属性上)new Pipe()
创建 socket
通信,调用 pipe_wrap
ipc
ipcFd
建立进程间的双向通信,在 fork
时创建for
(i = 0; i < stdio.length; i++)
循环建立父子进程 socket 通信child_process
回调调用流程child._handle.spawn(options)
执行命令exitCode
为 0,表示执行成功,小于0表示失败onStreamRead
方法读取流中信息onStreamRead
每读取完一条流中信息,调用一次 onReadableStreamEnd
maybeClose()
中,判断所有socket
关闭后,关闭子进程onStreamRead
与 onReadableStreamEnd
均由 node net.Socket
提供,socket 管道数据变化的监听均有此 api 内部通过广播 emit
触发,而底层均由 pipe_wrap.cc
触发后通知给onStreamRead
与 onReadableStreamEnd
const child = exec('ls -al | grep node_modules', (err, stdout, stderr) => {
// 回调中的数据有 execFile 封装一次性把数据抛出,成为统一标准流
console.log(
'//:============================== exec callback ==============================://'
)
console.log(err)
console.log(stdout)
console.error(stderr)
console.log(
'//:============================== exec callback ==============================://'
)
})
// 下列代码直接监听 socket 的数据变化,数据按 chunk 来接收
child.stdout?.on('data', (chunk) => {
console.log('stdout data', chunk)
})
child.stdout?.on('close', () => {
console.log('stdout close')
})
child.stderr?.on('data', (chunk) => {
console.log('stderr data', chunk)
})
child.stderr?.on('close', () => {
console.log('stderr close')
})
// 'exit' 事件在子进程结束后触发。
// 当 'exit' 事件被触发时,子进程标准输入输出流可能仍处于打开状态。
// 会调用 maybeclose 执行上面的 callback
// 此后向 socket 广播 close 事件
//(如下 console.log('close!', code))先接受到,
// 随后 stdout 也接受到
child.on('exit', (code) => {
console.log('exit!', code)
})
// 在进程已结束并且子进程的标准输入输出流已关闭之后,则触发 'close' 事件
// 这与 'exit' 事件不同,因为多个进程可能共享相同的标准输入输出流。
// 'close' 事件将始终在 'exit' 或 'error'(如果子进程衍生失败)已经触发之后触发。
child.on('close', (code) => {
console.log('close!', code)
})
stdout data drwxr-xr-x 39 i7eo staff 1248 1 31 18:48 node_modules
exit! 0
stderr close
//:============================== exec callback ==============================://
null
drwxr-xr-x 39 i7eo staff 1248 1 31 18:48 node_modules
//:============================== exec callback ==============================://
close! 0
stdout close
exec
: 原理是调用 bin/shell -c
执行我们传入的 shell
脚本,调用 execFile
,但传参做了处理execFile
:原理是直接执行我们传入的 file
和 args
,底层调用 spawn
创建和执行子进程,但通过监听 spawn
中广播的事件,建立了回调,且一次性将所有的 stdout
和 stderr
结果返回spawn
:原理是调用 internal/child_process
,实例化了 ChildProcess
子进程对象,再调用 ChildProcess.prototype.spawn()
创建子进程并执行命令,底层调用了 child._handle.spawn()
执行 C++ process_wrap
中的 spawn
方法。执行过程是异步的。执行完后,通过 pipe 进行单向数据通信,通信结束后,子进程发起 child._handle.onexit
回调,同时 socket 会执行 close
回调。fork
:原理是通过 spawn
创建子进程和执行命令。使用 node
执行命令,通过 setupchannel
创建 IPC
用于子进程和父进程之间的双向通信data
:主进程读取数据过程中,通过 onStreamRead
发起回调error
:命令执行失败后发起的回调exit
:子进程关闭完成后发起的回调close
:子进程所有 Socket
通信全部关闭后发起的回调stdout close
/stderr close
:特定的 PIPE 读取完成后调用 onReadableStreamEnd()
关闭 Socket
时发起的回调。add new - project
导入项目Root Directory
中选择要部署的项目,例如 apps/docs
,一直下一步点击 deploy 即可Settings
下拉找到Project ID
记做 projectIdSettings
下拉找到 Your ID
记做 orgidSettings
左侧列表点击Tokens
创建一个新的 token 并记录下来.github/workflow
俩个文件夹,进入 workflow
创建 deploy.yml
编写脚本即可。具体可参考:add new - project
导入项目Root Directory
中选择要部署的项目,例如 apps/docs
,一直下一步点击 deploy 即可。如果要部署 monorepo
下的多个项目,重复这俩步即可。api.value
api.value(0)
api.value(1)
api.value(2)
看得一头雾水。其实0
代表第一个维度,而api.value(0)
获得的值(即类目)在水平图表中指x轴数值,垂直图表中指y轴数值;1
代表第二个维度,而api.value(1)
获得的值(即系列)在水平图表中指y轴数值,垂直图表中指x轴数值。api.value
来获得某个点的坐标数据api.coord
api.value
同时出现,即通过api.value
获得某个点的坐标数据。注意是坐标数据,而不是真正的网页坐标。下一步把坐标数据传入api.coord
即可获得该点的网页坐标。例如:api.coord([api.value(0), api.value(1)])
params
xAxis
与yAxis
的配置即可。我们可以通过params
来获取类目与系列:{
// ...
renderItem(params, api) {
const catagroyIndex = params.dataIndex;
const seriesIndex = params.seriesIndex;
const seriesTopPoint = [catagroyIndex, seriesIndex]
const seriesBottomPoint = [catagroyIndex, 0]
const seriesTopLocation = api.coord(seriesTopPoint)
const seriesBottomLocation = api.coord(seriesBottomPoint)
// ...
}
// ...
}
// 绘制左侧面
const wid = 30;
const w1 = Math.sin(Math.PI / 6) * wid; //4
const w2 = Math.sin(Math.PI / 3) * wid; // 6.8
const snapHeight = wid / 2;
const CubeLeft = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath: function (ctx, shape) {
const xAxisPoint = shape.xAxisPoint;
const c0 = [shape.x, shape.y];
const c1 = [shape.x - w2, shape.y];
const c2 = [shape.x - w2, xAxisPoint[1]];
const c3 = [shape.x, xAxisPoint[1]];
ctx.moveTo(c0[0], c0[1]).lineTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1]).closePath();
},
});
// 绘制右侧面
const CubeRight = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath: function (ctx, shape) {
const xAxisPoint = shape.xAxisPoint;
const c1 = [shape.x, shape.y];
const c2 = [shape.x, xAxisPoint[1]];
const c3 = [shape.x + w1, xAxisPoint[1] - w2 + snapHeight];
const c4 = [shape.x + w1, shape.y - w2 + snapHeight];
ctx.moveTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1]).lineTo(c4[0], c4[1]).closePath();
},
});
// 绘制顶面
const CubeTop = echarts.graphic.extendShape({
shape: {
x: 0,
y: 0,
},
buildPath: function (ctx, shape) {
//
const c1 = [shape.x, shape.y];
const c2 = [shape.x + w1, shape.y - w2 + snapHeight]; //右点
const c3 = [shape.x - w2 + w1, shape.y - w2 + snapHeight];
const c4 = [shape.x - w2, shape.y];
ctx.moveTo(c1[0], c1[1]).lineTo(c2[0], c2[1]).lineTo(c3[0], c3[1]).lineTo(c4[0], c4[1]).closePath();
},
});
// 三个面图形
echarts.graphic.registerShape('CubeLeft', CubeLeft);
echarts.graphic.registerShape('CubeRight', CubeRight);
echarts.graphic.registerShape('CubeTop', CubeTop);
let xData = ['2017', '2018', '2019', '2020', '2021'];
let yData = [150, 126, 260, 220, 184];
var option = {
backgroundColor: '#000a3f',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
backgroundColor: 'rgba(9, 24, 48, 0.5)',
borderColor: 'rgba(75, 253, 238, 0.4)',
textStyle: {
color: '#CFE3FC',
},
borderWidth: 1,
formatter: function (params) {
let item = '';
item += params[0].name;
$(params).each(function (idx, itm) {
item += ': ' + itm.value+'万元';
});
return item;
},
},
grid: {
top: '10%',
left: '5%',
bottom: '5%',
right: '5%',
containLabel: true,
},
xAxis: {
type: 'category',
data: xData,
axisLine: {
show: true,
lineStyle: {
color: '#3e6f8e',
width: 1,
},
},
axisTick: {
show: false,
length: 9,
alignWithLabel: false,
lineStyle: {
color: '#AAA',
},
},
axisLabel: {
fontSize: 14,
margin: 10,
color: 'white',
},
splitLine: {
show: false,
lineStyle: {
color: '#ffffff',
opacity: 0.2,
width: 1,
},
},
},
yAxis: {
name: '单位:万元',
type: 'value',
nameTextStyle: {
color: 'white',
fontSize: 16,
},
axisLine: {
show: true,
lineStyle: {
color: '#3e6f8e',
width: 1,
},
},
axiosTick: {
show: false,
},
axisLabel: {
color: 'white',
fontSize: 14,
margin: 10,
},
splitLine: {
show: true,
lineStyle: {
color: '#ffffff',
opacity: 0.2,
width: 1,
},
},
nameGap: 20,
},
series: [
{
type: 'bar',
label: {
normal: {
show: true,
position: 'top',
fontSize: 16,
color: '#fff',
offset: [0, -10],
},
},
tooltip: {
show: false,
},
itemStyle: {
color: 'transparent',
},
data: yData,
},
{
type: 'custom',
renderItem: (params, api) => {
const location = api.coord([api.value(0), api.value(1)]);
location[0] = location[0] + wid * 0;
const xlocation = api.coord([api.value(0), 0]);
xlocation[0] = xlocation[0] + wid * 0;
return {
type: 'group',
children: [
{
type: 'CubeLeft',
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: xlocation,
},
style: {
fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: '#059de6',
},
{
offset: 1,
color: '#059de6',
},
]),
},
},
{
type: 'CubeRight',
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: xlocation,
},
style: {
fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: '#254193',
},
{
offset: 1,
color: '#254193',
},
]),
},
},
{
type: 'CubeTop',
shape: {
api,
xValue: api.value(0),
yValue: api.value(1),
x: location[0],
y: location[1],
xAxisPoint: xlocation,
},
style: {
fill: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: '#17e0fe',
},
{
offset: 1,
color: '#17e0fe',
},
]),
},
},
],
};
},
color: 'blue',
data: yData,
},
],
};
registerShape
、extendShape
、custom
等。// 3DBar.ts
import type { graphic, CustomSeriesRenderItemAPI } from 'echarts'
type ExtendShape = typeof graphic.extendShape
type RegisterShape = typeof graphic.registerShape
export interface Shape {
seriesTopLocation: number[]
seriesBottomLocation: number[]
api: CustomSeriesRenderItemAPI
}
/** 3dbar顶部默认宽度(像素)*/
const HORIZONTAL_BAR_TOP_WIDTH = 18;
/** 3dbar顶部默认高度(像素)*/
const HORIZONTAL_BAR_TOP_HEIGHT = 8;
/** 3dbar顶部默认偏移(像素)*/
const HORIZONTAL_BAR_TOP_OFFSET = 2;
/**
* 计算比例
*
* @param api
* @returns
*/
function getScale(api: CustomSeriesRenderItemAPI) {
// 获取一个像素对应的坐标
const point = api.size!([0, 1]);
const y = point[1];
// return y / 1; // 需要等比缩放再放开
return 1;
}
/**
* 根据比例换算数据
*
* @param scale
* @param width
* @param height
* @param offsetX
* @returns
*/
function getScaledBarInfo(
scale: number,
width: number,
height: number,
offsetX: number
) {
return {
width: width * scale,
height: height * scale,
offsetX: offsetX * scale
};
}
/**
* 使用canvas画3d的柱形
*
* 注意⚠️:
* 1. 所画图形还是2d,t/l/b/r 代表顶/左/底/右四个点(把这个面理解为棱形即可,顺时针)
*
* @param extendShape 自定义图形
* @param registerShape 向echarts实例注册自定义图形
*/
export function creator(extendShape: ExtendShape, registerShape: RegisterShape) {
// 绘制顶面
registerShape(
'3DBarTop',
extendShape({
shape: {
x: 0,
y: 0
},
buildPath: function (ctx, shape: Shape) {
// 不规则正方形
const { seriesTopLocation, api } = shape;
const [x1, y1] = seriesTopLocation;
const scale = getScale(api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
BAR_TOP_WIDTH,
BAR_TOP_HEIGHT,
BAR_TOP_OFFSET
);
const t = [x1, y1];
const l = [x1 - width / 2, y1 + height / 2];
const b = [x1 + offsetX, y1 + height];
const r = [x1 + width / 2 + offsetX, y1 + height / 2];
ctx
.moveTo(t[0], t[1])!
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
}
})
);
// 绘制左侧面
registerShape(
'3DBarLeft',
extendShape({
shape: {
x: 0,
y: 0
},
buildPath: function (ctx, shape: Shape) {
const { seriesTopLocation, seriesBottomLocation, api } = shape;
const [x1, y1] = seriesTopLocation;
const [x2, y2] = seriesBottomLocation;
const scale = getScale(api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
BAR_TOP_WIDTH,
BAR_TOP_HEIGHT,
BAR_TOP_OFFSET
);
const t = [x1 - width / 2, y1 + height / 2];
const l = [x2 - width / 2, y2 - height / 2];
const b = [x2 + offsetX, y2];
const r = [x1 + offsetX, y1 + height];
ctx
.moveTo(t[0], t[1])!
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
}
})
);
// 绘制右侧面
registerShape(
'3DBarRight',
extendShape({
shape: {
x: 0,
y: 0
},
buildPath: function (ctx, shape: Shape) {
const { seriesTopLocation, seriesBottomLocation, api } = shape;
const [x1, y1] = seriesTopLocation;
const [x2, y2] = seriesBottomLocation;
const scale = getScale(api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
BAR_TOP_WIDTH,
BAR_TOP_HEIGHT,
BAR_TOP_OFFSET
);
const t = [x1 + offsetX, y1 + height];
const l = [x2 + offsetX, y2];
const b = [x1 + width / 2 + offsetX, y2 - height / 2];
const r = [x1 + width / 2 + offsetX, y1 + height / 2];
ctx
.moveTo(t[0], t[1])!
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
}
})
);
}
// main.ts
import type { CustomSeriesRenderItemAPI, CustomSeriesRenderItemParams } from 'echarts'
import type { Shape } from './3dBar'
// ...
{
type: 'custom',
renderItem(params: CustomSeriesRenderItemAPI, api: CustomSeriesRenderItemParams) {
const catagroyIndex = params.dataIndex;
const seriesIndex = params.seriesIndex;
const seriesTopPoint = [catagroyIndex, seriesIndex]
const seriesBottomPoint = [catagroyIndex, 0]
const seriesTopLocation = api.coord(seriesTopPoint)
const seriesBottomLocation = api.coord(seriesBottomPoint)
const shape: Shape = {
seriesTopLocation,
seriesBottomLocation,
api
}
return {
type: 'group',
children: [
{
type: '3DBarTop',
shape,
style: {/* ... */}
},
{
type: '3DBarLeft',
shape,
style: {/* ... */}
},
{
type: '3DBarRight',
shape,
style: {/* ... */}
}
]
}
}
}
// ...
/* :========================================:: direction: "horizontal" ::========================================: */
/** 3dbar顶部默认宽度(像素)*/
const HORIZONTAL_BAR_TOP_WIDTH = 18;
/** 3dbar顶部默认高度(像素)*/
const HORIZONTAL_BAR_TOP_HEIGHT = 8;
/** 3dbar顶部默认偏移(像素)*/
const HORIZONTAL_BAR_TOP_OFFSET = 2;
/** 3dbar默认最小高度(顶点到底点距离,在这个高度能才能完整渲染3dbar)(像素)*/
const HORIZONTAL_BAR_MIN_HEIGHT = HORIZONTAL_BAR_TOP_HEIGHT + HORIZONTAL_BAR_TOP_HEIGHT / 2;
/** 3dbar之间的偏移(像素)*/
const HORIZONTAL_3DBAR_GAP = HORIZONTAL_BAR_TOP_HEIGHT / 2;
/* :========================================:: direction: "horizontal" ::========================================: */
/* :========================================:: direction: "column" ::========================================: */
/** 3dbar顶部默认宽度(像素)*/
const VERTICAL_BAR_TOP_WIDTH = 12;
/** 3dbar顶部默认高度(像素)*/
const VERTICAL_BAR_TOP_HEIGHT = 18;
/** 3dbar顶部默认偏移(像素)*/
const VERTICAL_BAR_TOP_OFFSET = 2;
/** 3dbar默认最小高度(顶点到底点距离,在这个高度能才能完整渲染3dbar)(像素)*/
const VERTICAL_BAR_MIN_WIDTH = VERTICAL_BAR_TOP_WIDTH + VERTICAL_BAR_TOP_WIDTH / 2;
/** 3dbar之间的偏移(像素)*/
const VERTICAL_3DBAR_GAP = VERTICAL_BAR_TOP_WIDTH / 2;
/* :========================================:: direction: "column" ::========================================: */
/* :========================================:: common ::========================================: */
/** stack为false情况下的偏移量(像素)*/
const DEFAULT_NO_STACK_OFFSET = 16;
/** 记录series每次的坐标用于碰撞检测*/
const SERIES_COORD_MAP = new Map<string, any>();
/* :========================================:: common ::========================================: */
/**
* 计算当前维度中当前系列以前的所有系列数值之和
*
* @param useStack
* @param dataset 包含维度/系列组合数值的数据集合,一般为二维数组。一维表示 catagroy,二维表示 series
* @param catagroyIndex 维度索引如:心理所|声学所
* @param seriesIndex 系列索引如:第一次退出|第二次退出
*/
export function fetchPrevSeriesValue(
useStack = true,
dataset: any[],
catagroyIndex: number,
seriesIndex: number
) {
// 获取当前维度serires前的每一项的数值
// 遍历当前bar之前的所有bar,得出当前bar的数值
let prevSeriesValue = 0;
if (!useStack) {
return prevSeriesValue;
} else {
for (let i = seriesIndex - 1; i >= 0; i--) {
prevSeriesValue += dataset[catagroyIndex][i];
}
return prevSeriesValue;
}
}
/**
* 通过坐标判断是否满足bar的最小高度,不满足的话手动修改y坐标,上移HORIZONTAL_BAR_MIN_HEIGHT
*
* @param direction
* @param isRenderDataZero 是否渲染数据0
* @param seriesTopLocation
* @param seriesBottomLocation
* @returns
*/
export function handleSeriesMinHeight(
direction: "horizontal" | "column",
isRenderDataZero: boolean,
seriesTopLocation: number[],
seriesBottomLocation: number[]
) {
if (direction === "horizontal") {
if (seriesTopLocation[1] - seriesBottomLocation[1] < 0) {
// 正常情况下top的y坐标比botton的y坐标小
if (Math.abs(seriesTopLocation[1] - seriesBottomLocation[1]) < HORIZONTAL_BAR_MIN_HEIGHT) {
seriesTopLocation[1] = seriesBottomLocation[1] - HORIZONTAL_BAR_MIN_HEIGHT;
}
} else if (seriesTopLocation[1] - seriesBottomLocation[1] === 0) {
if (!isRenderDataZero) {
// 数据值为0,什么都不做。
} else {
//得先把top的y放到bottom的y上面
seriesTopLocation[1] =
seriesTopLocation[1] - (seriesTopLocation[1] - seriesBottomLocation[1]) * 2;
seriesTopLocation[1] = seriesBottomLocation[1] - HORIZONTAL_BAR_MIN_HEIGHT;
}
} else {
// 非正常情况(在handleSeriesImpact检测碰撞后,top的y坐标比botton的y坐标大)需要手动修改
//得先把top的y放到bottom的y上面
seriesTopLocation[1] =
seriesTopLocation[1] - (seriesTopLocation[1] - seriesBottomLocation[1]) * 2;
seriesTopLocation[1] = seriesBottomLocation[1] - HORIZONTAL_BAR_MIN_HEIGHT;
}
} else {
if (seriesTopLocation[0] - seriesBottomLocation[0] > 0) {
// 正常情况下top的y坐标比botton的y坐标小
if (Math.abs(seriesTopLocation[0] - seriesBottomLocation[0]) < VERTICAL_BAR_MIN_WIDTH) {
seriesTopLocation[0] = seriesBottomLocation[0] + VERTICAL_BAR_MIN_WIDTH;
}
} else if (seriesTopLocation[0] - seriesBottomLocation[0] === 0) {
if (!isRenderDataZero) {
// 数据值为0,什么都不做。
} else {
//得先把top的y放到bottom的y上面
seriesTopLocation[0] =
seriesTopLocation[0] - (seriesTopLocation[0] - seriesBottomLocation[0]) * 2;
seriesTopLocation[0] = seriesBottomLocation[0] + VERTICAL_BAR_MIN_WIDTH;
}
} else {
// 非正常情况(在handleSeriesImpact检测碰撞后,top的y坐标比botton的y坐标大)需要手动修改
//得先把top的y放到bottom的y上面
seriesTopLocation[0] =
seriesTopLocation[0] - (seriesTopLocation[0] - seriesBottomLocation[0]) * 2;
seriesTopLocation[0] = seriesBottomLocation[0] + VERTICAL_BAR_MIN_WIDTH;
}
}
return [seriesTopLocation, seriesBottomLocation];
}
/**
* 获取缓存坐标map每一项的key
*
* @param catagroyIndex
* @param seriesIndex
* @returns
*/
export function getCacheSeriesCoordMapKey(catagroyIndex: number, seriesIndex: number) {
return `${catagroyIndex}-${seriesIndex}`;
}
/**
* 缓存坐标
*
* @param seriesTopLocation
* @param seriesBottomLocation
* @param catagroyIndex
* @param seriesIndex
* @param seriesCoordMap
* @returns
*/
export function cacheSeriesCoord(
seriesTopLocation: number[],
seriesBottomLocation: number[],
catagroyIndex: number,
seriesIndex: number,
seriesCoordMap: Map<string, any> = SERIES_COORD_MAP
) {
const key = getCacheSeriesCoordMapKey(catagroyIndex, seriesIndex);
// 不能使用has判断,因为开启datazoom后鼠标移动画布也会导致重绘图,此时需要更新map中的坐标
seriesCoordMap.set(key, [seriesTopLocation, seriesBottomLocation]);
return seriesCoordMap;
}
/**
* 控制是否渲染数据0,返回true表示渲染,程序继续执行返回3dbar;返回false则程序中断,返回空即可不渲染数据0
*
* @param direction
* @param isRenderDataZero
* @param seriesTopLocation
* @param seriesBottomLocation
* @returns
*/
export function handleRenderZero(
direction: "horizontal" | "column",
isRenderDataZero: boolean,
seriesTopLocation: number[],
seriesBottomLocation: number[]
) {
if (direction === "horizontal") {
if (!isRenderDataZero) {
if (seriesTopLocation[1] - seriesBottomLocation[1] === 0) return false;
return true;
} else {
return true;
}
} else {
if (!isRenderDataZero) {
if (seriesTopLocation[0] - seriesBottomLocation[0] === 0) return false;
return true;
} else {
return true;
}
}
}
/**
* 使用当前series底点的y坐标与上一个series顶点的y坐标进行碰撞检测
*
* @param isRenderDataZero 是否渲染数据0
* @param direction
* @param useStack
* @param catagroyIndex
* @param seriesIndex
* @param seriesCoordMap
* @returns
*/
export function handleSeriesImpact(
isRenderDataZero: boolean,
direction: "horizontal" | "column",
useStack = true,
catagroyIndex: number,
seriesIndex: number,
seriesCoordMap: Map<string, any>
) {
const getTargetSeries = (
catagroyIndex: number,
seriesIndex: number,
seriesCoordMap: Map<string, any>
) => {
const key = getCacheSeriesCoordMapKey(catagroyIndex, seriesIndex);
return seriesCoordMap.get(key);
};
if (direction === "horizontal") {
let _seriesTopLocation: number[] = [];
let _seriesBottomLocation: number[] = [];
// 当前series坐标
const [seriesTopLocation, seriesBottomLocation] = getTargetSeries(
catagroyIndex,
seriesIndex,
seriesCoordMap
);
if (!useStack) {
// 关闭stack后不继续执行
return [seriesTopLocation, seriesBottomLocation];
}
if (seriesIndex > 0) {
// 从第2个series开始,每次与上一个series进行碰撞检测
// 上一个series坐标
const [preSeriesTopLocation, preSeriesBottomLocation] = getTargetSeries(
catagroyIndex,
seriesIndex - 1,
seriesCoordMap
);
// 使用当前series底点的y坐标与上一个series顶点的y坐标进行碰撞检测
// 让当前series的底点与上一个series的顶点贴合
seriesBottomLocation[1] = preSeriesTopLocation[1];
// 确保当前series的高度
const location = handleSeriesMinHeight(
direction,
isRenderDataZero,
seriesTopLocation,
seriesBottomLocation
);
_seriesTopLocation = location[0];
_seriesBottomLocation = location[1];
} else {
// 第1个series不用碰撞检测
_seriesTopLocation = seriesTopLocation;
_seriesBottomLocation = seriesBottomLocation;
}
return [_seriesTopLocation, _seriesBottomLocation];
} else {
// 水平方向
let _seriesTopLocation: number[] = [];
let _seriesBottomLocation: number[] = [];
// 当前series坐标
const [seriesTopLocation, seriesBottomLocation] = getTargetSeries(
catagroyIndex,
seriesIndex,
seriesCoordMap
);
if (!useStack) {
// 关闭stack后不继续执行
return [seriesTopLocation, seriesBottomLocation];
}
if (seriesIndex > 0) {
// 从第2个series开始,每次与上一个series进行碰撞检测
// 上一个series坐标
const [preSeriesTopLocation, preSeriesBottomLocation] = getTargetSeries(
catagroyIndex,
seriesIndex - 1,
seriesCoordMap
);
// 使用当前series底点的y坐标与上一个series顶点的y坐标进行碰撞检测
// 让当前series的底点与上一个series的顶点贴合
seriesBottomLocation[0] = preSeriesTopLocation[0];
// 确保当前series的高度
const location = handleSeriesMinHeight(
direction,
isRenderDataZero,
seriesTopLocation,
seriesBottomLocation
);
_seriesTopLocation = location[0];
_seriesBottomLocation = location[1];
} else {
// 第1个series不用碰撞检测
_seriesTopLocation = seriesTopLocation;
_seriesBottomLocation = seriesBottomLocation;
}
return [_seriesTopLocation, _seriesBottomLocation];
}
}
/**
* 处理每个系列之间的间隙,x/y轴变化,第一个不用处理
*
* @param direction
* @param useStack
* @param seriesIndex
* @param seriesTopLocation
* @param seriesBottomLocation
* @param offset
* @returns
*/
export function handleSeriesOffset(
direction: "horizontal" | "column",
useStack = true,
seriesIndex: number,
seriesTopLocation: number[],
seriesBottomLocation: number[],
offset?: number
) {
if (seriesIndex > 0) {
if (direction === "horizontal") {
const _offset = offset || HORIZONTAL_3DBAR_GAP;
if (!useStack) {
seriesTopLocation[0] = seriesTopLocation[0] + DEFAULT_NO_STACK_OFFSET * 2;
seriesBottomLocation[0] = seriesBottomLocation[0] + DEFAULT_NO_STACK_OFFSET * 2;
} else {
seriesTopLocation[1] = seriesTopLocation[1] + _offset;
seriesBottomLocation[1] = seriesBottomLocation[1] + _offset;
}
} else {
// 竖直
const _offset = offset || VERTICAL_3DBAR_GAP;
if (!useStack) {
seriesTopLocation[1] = seriesTopLocation[1] + DEFAULT_NO_STACK_OFFSET * 2;
seriesBottomLocation[1] = seriesBottomLocation[1] + DEFAULT_NO_STACK_OFFSET * 2;
} else {
seriesTopLocation[0] = seriesTopLocation[0] - _offset;
seriesBottomLocation[0] = seriesBottomLocation[0] - _offset;
}
}
}
return [seriesTopLocation, seriesBottomLocation];
}
/**
* 角度转弧度
*
* @param angle
*/
function transformAngleToRadian(angle: number) {
return angle * (Math.PI / 180);
}
/** 顶部旋转角度 */
const DEFAULT_BAR_TOP_ROTATION_ANGLE = 30;
/**
* 根据顶点坐标、边长、旋转角度来计算除顶点外其他三个点的坐标
*
* t => t' [x, y]
*
* l => l' [x + width * sin(angle), y + width * cos(angle)]
*
* b => b' [x + √2 * width * cos(90/2 + angle), y + √2 * width * sin(90/2 + angle)]
*
* r => r' [x + width * cos(angle), y + width * sin(angle)]
*
* 其中根号2直接取 1.414
*
* @param location
* @param width
* @param angle
*/
function calcBarTopLocation(location: number[], width: number, angle: number) {
const sqrt2 = Math.sqrt(2);
const [x, y] = location;
const t = [x, y];
const l = [
x - width * Math.sin(transformAngleToRadian(angle)),
y + width * Math.cos(transformAngleToRadian(angle))
];
const b = [
x + sqrt2 * width * Math.cos(transformAngleToRadian(45 + angle)),
y + sqrt2 * width * Math.sin(transformAngleToRadian(45 + angle))
];
const r = [
x + width * Math.cos(transformAngleToRadian(angle)),
y + width * Math.sin(transformAngleToRadian(angle))
];
return [t, l, b, r];
}
/**
* 计算比例
*
* @param direction
* @param api
* @returns
*/
function getScale(direction: "horizontal" | "column", api: any) {
if (direction === "horizontal") {
// 获取一个像素对应的坐标
const point = api.size([0, 1]);
const y = point[1];
// return y / 1; // 需要等比缩放再放开
return 1;
} else {
return 1;
}
}
function getScaledBarInfo(scale: number, width: number, height: number, offsetX: number) {
return {
width: width * scale,
height: height * scale,
offsetX: offsetX * scale
};
}
/**
* 使用canvas画3d的柱形
*
* 注意⚠️:
* 1. 所画图形还是2d,t/l/b/r 代表顶/左/底/右四个点(把这个面理解为棱形即可,顺时针)
*
* @param extendShape 自定义图形
* @param registerShape 向echarts实例注册自定义图形
*/
export function creator(extendShape, registerShape) {
// 绘制顶面
registerShape(
"3DBarTop",
extendShape({
shape: {
x: 0,
y: 0
},
buildPath: function (ctx, shape) {
if (shape.direction === "horizontal") {
// 纯正方形
// const {
// seriesTopInfo = { width: HORIZONTAL_BAR_TOP_WIDTH, angle: DEFAULT_BAR_TOP_ROTATION_ANGLE },
// seriesTopLocation
// } = shape;
// const [t, l, b, r] = calcBarTopLocation(
// seriesTopLocation,
// seriesTopInfo.width,
// seriesTopInfo.angle
// );
// ctx.moveTo(t[0], t[1]).lineTo(l[0], l[1]).lineTo(b[0], b[1]).lineTo(r[0], r[1]).closePath();
// ui要求不规则正方形
const { seriesTopLocation, direction, useStack, api } = shape;
const [x1, y1] = seriesTopLocation;
const scale = getScale(direction, api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
HORIZONTAL_BAR_TOP_WIDTH,
HORIZONTAL_BAR_TOP_HEIGHT,
HORIZONTAL_BAR_TOP_OFFSET
);
let t: number[] = [];
let l: number[] = [];
let b: number[] = [];
let r: number[] = [];
if (!useStack) {
const noStackOffsetX = DEFAULT_NO_STACK_OFFSET;
t = [x1 - noStackOffsetX, y1];
l = [x1 - width / 2 - noStackOffsetX, y1 + height / 2];
b = [x1 + offsetX - noStackOffsetX, y1 + height];
r = [x1 + width / 2 + offsetX - noStackOffsetX, y1 + height / 2];
} else {
t = [x1, y1];
l = [x1 - width / 2, y1 + height / 2];
b = [x1 + offsetX, y1 + height];
r = [x1 + width / 2 + offsetX, y1 + height / 2];
}
ctx
.moveTo(t[0], t[1])
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
} else {
// 竖直方向
// ui要求不规则正方形
const { seriesTopLocation: seriesRightLocation, direction, useStack, api } = shape;
const [x1, y1] = seriesRightLocation;
const scale = getScale(direction, api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
VERTICAL_BAR_TOP_WIDTH,
VERTICAL_BAR_TOP_HEIGHT,
VERTICAL_BAR_TOP_OFFSET
);
let t: number[] = [];
let l: number[] = [];
let b: number[] = [];
let r: number[] = [];
if (!useStack) {
// const noStackOffsetX = DEFAULT_NO_STACK_OFFSET;
t = [x1 - width / 2, y1 - height / 2];
l = [x1 - width, y1 + offsetX];
b = [x1 - width / 2, y1 + height / 2 + offsetX];
r = [x1, y1];
} else {
t = [x1 - width / 2, y1 - height / 2];
l = [x1 - width, y1 + offsetX];
b = [x1 - width / 2, y1 + height / 2 + offsetX];
r = [x1, y1];
}
ctx
.moveTo(t[0], t[1])
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
}
}
})
);
// 绘制左侧面
registerShape(
"3DBarLeft",
extendShape({
shape: {
x: 0,
y: 0
},
buildPath: function (ctx, shape) {
if (shape.direction === "horizontal") {
const { seriesTopLocation, seriesBottomLocation, direction, useStack, api } = shape;
const [x1, y1] = seriesTopLocation;
const [x2, y2] = seriesBottomLocation;
const scale = getScale(direction, api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
HORIZONTAL_BAR_TOP_WIDTH,
HORIZONTAL_BAR_TOP_HEIGHT,
HORIZONTAL_BAR_TOP_OFFSET
);
let t: number[] = [];
let l: number[] = [];
let b: number[] = [];
let r: number[] = [];
if (!useStack) {
const noStackOffsetX = DEFAULT_NO_STACK_OFFSET;
t = [x1 - width / 2 - noStackOffsetX, y1 + height / 2];
l = [x2 - width / 2 - noStackOffsetX, y2 - height / 2];
b = [x2 + offsetX - noStackOffsetX, y2];
r = [x1 + offsetX - noStackOffsetX, y1 + height];
} else {
t = [x1 - width / 2, y1 + height / 2];
l = [x2 - width / 2, y2 - height / 2];
b = [x2 + offsetX, y2];
r = [x1 + offsetX, y1 + height];
}
ctx
.moveTo(t[0], t[1])
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
} else {
// 竖直方向
const {
seriesTopLocation: seriesRightLocation,
seriesBottomLocation: seriesLeftLocation,
direction,
useStack,
api
} = shape;
const [x2, y2] = seriesLeftLocation;
const [x1, y1] = seriesRightLocation;
const scale = getScale(direction, api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
VERTICAL_BAR_TOP_WIDTH,
VERTICAL_BAR_TOP_HEIGHT,
VERTICAL_BAR_TOP_OFFSET
);
let t: number[] = [];
let l: number[] = [];
let b: number[] = [];
let r: number[] = [];
if (!useStack) {
// const noStackOffsetX = DEFAULT_NO_STACK_OFFSET;
t = [x1 - width / 2, y1 - height / 2];
l = [x2 + width / 2, y1 - height / 2];
b = [x2, y1 + offsetX];
r = [x1 - width, y1 + offsetX];
} else {
t = [x1 - width / 2, y1 - height / 2];
l = [x2 + width / 2, y1 - height / 2];
b = [x2, y1 + offsetX];
r = [x1 - width, y1 + offsetX];
}
ctx
.moveTo(t[0], t[1])
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
}
}
})
);
// 绘制右侧面
registerShape(
"3DBarRight",
extendShape({
shape: {
x: 0,
y: 0
},
buildPath: function (ctx, shape) {
if (shape.direction === "horizontal") {
const { seriesTopLocation, seriesBottomLocation, direction, useStack, api } = shape;
const [x1, y1] = seriesTopLocation;
const [x2, y2] = seriesBottomLocation;
const scale = getScale(direction, api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
HORIZONTAL_BAR_TOP_WIDTH,
HORIZONTAL_BAR_TOP_HEIGHT,
HORIZONTAL_BAR_TOP_OFFSET
);
let t: number[] = [];
let l: number[] = [];
let b: number[] = [];
let r: number[] = [];
if (!useStack) {
const noStackOffsetX = DEFAULT_NO_STACK_OFFSET;
t = [x1 + offsetX - noStackOffsetX, y1 + height];
l = [x2 + offsetX - noStackOffsetX, y2];
b = [x1 + width / 2 + offsetX - noStackOffsetX, y2 - height / 2];
r = [x1 + width / 2 + offsetX - noStackOffsetX, y1 + height / 2];
} else {
t = [x1 + offsetX, y1 + height];
l = [x2 + offsetX, y2];
b = [x1 + width / 2 + offsetX, y2 - height / 2];
r = [x1 + width / 2 + offsetX, y1 + height / 2];
}
ctx
.moveTo(t[0], t[1])
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
} else {
// 竖直方向
const {
seriesTopLocation: seriesRightLocation,
seriesBottomLocation: seriesLeftLocation,
direction,
useStack,
api
} = shape;
const [x2, y2] = seriesLeftLocation;
const [x1, y1] = seriesRightLocation;
const scale = getScale(direction, api);
const { width, height, offsetX } = getScaledBarInfo(
scale,
VERTICAL_BAR_TOP_WIDTH,
VERTICAL_BAR_TOP_HEIGHT,
VERTICAL_BAR_TOP_OFFSET
);
let t: number[] = [];
let l: number[] = [];
let b: number[] = [];
let r: number[] = [];
if (!useStack) {
// const noStackOffsetX = DEFAULT_NO_STACK_OFFSET;
t = [x1 - width, y1 + offsetX];
l = [x2, y1 + offsetX];
b = [x2 + width / 2, y1 + height / 2 + offsetX];
r = [x1 - width / 2, y1 + height / 2 + offsetX];
} else {
t = [x1 - width, y1 + offsetX];
l = [x2, y1 + offsetX];
b = [x2 + width / 2, y1 + height / 2 + offsetX];
r = [x1 - width / 2, y1 + height / 2 + offsetX];
}
ctx
.moveTo(t[0], t[1])
.lineTo(l[0], l[1])
.lineTo(b[0], b[1])
.lineTo(r[0], r[1])
.closePath();
}
}
})
);
}
<Tooltip>
组件,但是会面临以下问题:<Tooltip>
代码Tooltip
组件的函数即可。在单元格渲染完毕后,自动调用即可,这样就不需要再重复编写相同的代码。Tooltip
。这里的重点在于如何获取真实宽度。真实宽度只能在 render (页面内容呈现出来)后获取,结合 Tooltip
出现的交互,用户鼠标移入该单元格后 Tooltip
显示。所以我们只需要在单元格上添加事件 Mouseenter/Mouseleave
即可。Vue3.x
, React
类似。示例代码不可用于生产🙅♂️,目的是阐述思路。// Cell.tsx
//...
const instances = new Map<string, any>()
const cellRef = ref<HTMLElement | null>(null)
/**
* 获取单元格真实宽度判断是否溢出
* @param cellContentEl
* @returns
*/
function isCellOverflow(cellContentEl: HTMLElement) {
if(!cellContentEl) return false
return cellContentEl.scrollWidth > cellContentEl.clientWidth
}
/**
* 获取单元格内容(需要考虑单元格为组件的情况,这里需要获取真实 DOM)
* @param cellContentEl
* @returns
*/
function getCellContent(cellContentEl: HTMLElement) {
if(!cellContentEl) return ''
return (cellContentEl.innerText ?? cellContentEl.textContent ?? '').trim()
}
/**
* 展示 Tooltip
* @param instances
* @param params
* @returns
*/
function showCellTooltip(
instances: Map<string, any>,
row: Record<string, any>
) {
const id = `row_${row.id}`
const el = unref(cellRef)
const title = getCellContent(el)
const isCellOverflow = isCellOverflow(el)
let instance
if (!instances.has(id)) {
instance = $Tooltip(el as HTMLElement, {
placement: 'top',
title,
id,
delay: 100,
})
instances.set(id, instance)
} else {
instance = instances.get(id)
}
isCellOverflow && instance?.showTooltip()
}
/**
* 消失 Tooltip
* @param instances
* @param params
* @returns
*/
function hideCellTooltip(
instances: Map<string, any>,
row: Record<string, any>
) {
const id = `row_${row.id}-${column.id}`
const instance = instances.get(id)
instance?.hideTooltip()
}
const handleCellMouseenter = ({ row }) => {
showCellTooltip(instances, row)
}
const handleCellMouseleave = ({ row }) => {
hideCellTooltip(instances, row)
}
return () => {
return (
<div class="custom-cell">
<div
class="custom-cell-content"
ref={cellRef}
onMouseenter={handleCellMouseenter}
onMouseleave={handleCellMouseleave}
>
{slots.default?()}
</div>
</div>
)
}
// ...
isCellOverflow
与getCellContent
函数,isCellOverflow
来判断是否溢出;getCellContent
用来兜底单元格的内容,因为单元格支持可以自定义渲染,我们在不能保证单元格最终渲染为什么的时候,只能这样做(这也是通过 Mouseenter/Mouseleave
来实现的优势)// useTooltip.tsx
import { onMounted, onUnmounted } from 'vue'
import { arrow, computePosition, flip, hide, offset, shift } from '@floating-ui/dom'
import { useThrottleFn } from '@vueuse/core'
import type { Placement } from '@floating-ui/dom'
const CLS = `ta-popper`
interface TooltipProps {
/** tooltip 内容 */
title: string
/** tooltip 位置 */
placement: Placement
}
/**
* 生成 dom
* @param param
* @returns
*/
function createElement({ tagName = 'div', className = '' }) {
const el = document.createElement(tagName)
el.className = className
return el
}
/**
* 生成 Tooltip dom
* @param props
* @returns
*/
function createAntvTooltip(props: TooltipProps) {
const tooltipEl = createElement({
className: `${CLS}`,
})
const contentEl = createElement({
tagName: 'span',
className: `${CLS}-content`,
})
contentEl.innerHTML = props.title ?? ''
const arrowEl = createElement({
className: `${CLS}-arrow`,
})
tooltipEl.appendChild(contentEl)
tooltipEl.appendChild(arrowEl)
return {
tooltipEl,
arrowEl,
}
}
export function $Tooltip(cellEl: HTMLElement, props: TooltipProps) {
if (!cellEl) return
const { tooltipEl, arrowEl } = createAntvTooltip(props)
/**
* 使用 @floating-ui/dom 来实时更新 Tooltip 的位置,包括三角形位置,内容位置
* @param
* @returns
*/
function update() {
computePosition(cellEl, tooltipEl, {
placement: 'top-start',
middleware: [hide(), offset(6), flip(), shift({ padding: 6 }), arrow({ element: arrowEl })],
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(tooltipEl.style, {
left: `${x}px`,
top: `${y}px`,
})
const staticSide: any = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[placement.split('-')[0]]
Object.assign(arrowEl.style, {
// left: middlewareData.arrow?.x != null ? `${middlewareData.arrow?.x}px` : '',
left: '10px',
top: middlewareData.arrow?.y != null ? `${middlewareData.arrow?.y}px` : '',
right: '',
bottom: '',
[staticSide]: '-4px',
})
tooltipEl.dataset.popperPlacement = placement
})
}
function showTooltip() {
setTimeout(() => {
document.body.appendChild(tooltipEl)
update()
}, props.delay)
}
function hideTooltip() {
setTimeout(() => {
tooltipEl.remove()
}, props.delay)
}
return {
showTooltip,
hideTooltip,
}
}
/**
* 监听全局滚动,滚动时让 Tooltip 消失
* @param instances Tooltip 生成的实例
* @returns
*/
export function useHideTooltips(instances) {
if (!instances) return
const listener = () => {
for (const instance of instances.values()) {
if (instance) instance?.hideTooltip()
}
}
const handler = useThrottleFn(listener, 30)
onMounted(() => {
document.addEventListener('mousewheel', handler)
})
onUnmounted(() => {
document.removeEventListener('mousewheel', handler)
})
}
Tooltip
的方法,所以这部分需要自行编写。需要注意的是,自己编写时会遇到 Tooltip
在视口空间不够时不会自动调整位置的问题,所以我们需要借助@floating-ui/dom
,当前主流库都在使用 popper.js
但是这个包不支持 Tree-Shaking
,会导致打包产物变大。@floating-ui/dom
是popper.js
团队使用Typescript
编写的新项目,支持 Tree-Shaking
等现代化特性。详情查看:Tooltip
消失,监听滚动时使用节流函数避免性能消耗。