@antv/s2
Version:
effective spreadsheet render core lib
340 lines • 14.8 kB
JavaScript
/**
* 基于 g 绘制简单 mini 图工具库
* https://github.com/antvis/g
*/
import { isEmpty, isNil, map, max, min } from 'lodash';
import { CellType, MiniChartType } from '../common/constant';
import { CellClipBox, } from '../common/interface';
import { getIntervalScale } from '../utils/condition/condition';
import { parseNumberWithPrecision } from '../utils/formatter';
import { renderCircle, renderLine, renderPolyline, renderRect, } from '../utils/g-renders';
import { batchSetStyle } from '../utils/g-utils';
/**
* 坐标转换
*/
export const scale = (chartData, cell) => {
var _a;
const { data, encode, type } = chartData;
const { x, y, height, width } = cell.getMeta();
const dataCellStyle = cell.getStyle(CellType.DATA_CELL);
const { cell: cellStyle, miniChart } = dataCellStyle;
const measures = [];
const encodedData = map(data, (item) => {
measures.push(item === null || item === void 0 ? void 0 : item[encode.y]);
return {
x: item[encode.x],
y: item[encode.y],
};
});
const maxMeasure = max(measures) || 0;
const minMeasure = min(measures) || 0;
let measureRange = maxMeasure - minMeasure;
const { left = 0, right = 0, top = 0, bottom = 0 } = cellStyle.padding;
const xStart = x + left;
const xEnd = x + width - right;
const yStart = y + top;
const yEnd = y + height - bottom;
const heightRange = yEnd - yStart;
const intervalPadding = (_a = miniChart === null || miniChart === void 0 ? void 0 : miniChart.bar) === null || _a === void 0 ? void 0 : _a.intervalPadding;
let intervalX;
if (type === MiniChartType.Bar) {
intervalX =
(xEnd - xStart - (measures.length - 1) * intervalPadding) /
measures.length +
intervalPadding;
}
else {
intervalX =
measures.length > 1 ? (xEnd - xStart) / (measures.length - 1) : 0;
}
const box = [];
const points = map(encodedData, (item, key) => {
const positionX = xStart + key * intervalX;
let positionY;
if (measureRange !== 0) {
positionY =
yEnd - (((item === null || item === void 0 ? void 0 : item.y) - minMeasure) / measureRange) * heightRange;
}
else {
positionY = minMeasure > 0 ? yStart : yEnd;
}
if (type === MiniChartType.Bar) {
let baseLinePositionY;
let barHeight;
if (minMeasure < 0 && maxMeasure > 0 && measureRange !== 0) {
// 基准线(0 坐标)在中间
baseLinePositionY =
yEnd - ((0 - minMeasure) / measureRange) * heightRange;
barHeight = Math.abs(positionY - baseLinePositionY);
if ((item === null || item === void 0 ? void 0 : item.y) < 0) {
// 如果值小于 0 需要从基准线为起始 y 坐标开始绘制
positionY = baseLinePositionY;
}
}
else {
// 有三种情况:全为正数 / 全为负数 / 全为零值
// baseLinePositionY = minMeasure < 0 ? yStart : yEnd;
measureRange = max([Math.abs(maxMeasure), Math.abs(minMeasure)]);
if (measureRange === 0 && minMeasure === 0 && maxMeasure === 0) {
// 全为零值: 没有bar
barHeight = 0;
}
else {
// 全为非零值: 有bar 高度相同
barHeight =
measureRange === 0
? heightRange
: (Math.abs((item === null || item === void 0 ? void 0 : item.y) - 0) / measureRange) * heightRange;
}
if (minMeasure < 0) {
positionY = yStart;
}
else {
positionY = yEnd - barHeight;
}
}
const barWidth = intervalX - intervalPadding;
box.push([barWidth, barHeight]);
}
return [positionX, positionY];
});
return {
points,
box,
};
};
// ========================= mini 折线相关 ==============================
/**
* 过滤掉 NaN 值,返回有效的图表数据
*/
export const filterValidChartData = (chartData) => {
const { data, encode } = chartData;
const validData = data.filter((item) => {
const yValue = item === null || item === void 0 ? void 0 : item[encode.y];
return typeof yValue === 'number' && !Number.isNaN(yValue);
});
return Object.assign(Object.assign({}, chartData), { data: validData });
};
/**
* 绘制单元格内的 mini 折线图
*/
export const drawLine = (chartData, cell) => {
if (isEmpty(chartData === null || chartData === void 0 ? void 0 : chartData.data) || isEmpty(cell)) {
return;
}
// 过滤掉 NaN 值,只保留有效数据点
const validChartData = filterValidChartData(chartData);
// 如果过滤后没有有效数据,不绘制任何内容
if (isEmpty(validChartData.data)) {
return;
}
const dataCellStyle = cell.getStyle(CellType.DATA_CELL);
const { miniChart } = dataCellStyle;
const { point, linkLine } = miniChart === null || miniChart === void 0 ? void 0 : miniChart.line;
const { points } = scale(validChartData, cell);
// 当只有一个有效数据点时,只绘制一个点,而不是线
// 这是 BI / 数据可视化领域的通用共识
if (points.length > 1) {
renderPolyline(cell, {
points,
stroke: linkLine === null || linkLine === void 0 ? void 0 : linkLine.fill,
lineWidth: linkLine === null || linkLine === void 0 ? void 0 : linkLine.size,
opacity: linkLine === null || linkLine === void 0 ? void 0 : linkLine.opacity,
});
}
for (let i = 0; i < points.length; i++) {
renderCircle(cell, {
cx: points[i][0],
cy: points[i][1],
r: point.size,
fill: point.fill,
fillOpacity: point === null || point === void 0 ? void 0 : point.opacity,
});
}
};
// ========================= mini 柱状图相关 ==============================
/**
* 绘制单元格内的 mini 柱状图
*/
export const drawBar = (chartData, cell) => {
if (isEmpty(chartData === null || chartData === void 0 ? void 0 : chartData.data) || isEmpty(cell)) {
return;
}
const dataCellStyle = cell.getStyle(CellType.DATA_CELL);
const { miniChart } = dataCellStyle;
const { bar } = miniChart;
const { points, box } = scale(chartData, cell);
for (let i = 0; i < points.length; i++) {
renderRect(cell, {
x: points[i][0],
y: points[i][1],
width: box[i][0],
height: box[i][1],
fill: bar.fill,
fillOpacity: bar.opacity,
});
}
};
// ======================== mini 子弹图相关 ==============================
/**
* 根据当前值和目标值获取子弹图填充色
*/
export const getBulletRangeColor = (measure, target, rangeColors) => {
const delta = Number(target) - Number(measure);
if (Number.isNaN(delta) || Number(measure) < 0) {
return rangeColors.bad;
}
if (delta <= 0.1) {
return rangeColors.good;
}
if (delta > 0.1 && delta <= 0.2) {
return rangeColors.satisfactory;
}
return rangeColors.bad;
};
// 比率转百分比, 简单解决计算精度问题
export const transformRatioToPercent = (ratio, fractionDigits = { min: 0, max: 0 }) => {
var _a, _b;
const value = Number(ratio);
if (Number.isNaN(value)) {
return ratio;
}
const minimumFractionDigits = (_a = fractionDigits === null || fractionDigits === void 0 ? void 0 : fractionDigits.min) !== null && _a !== void 0 ? _a : fractionDigits;
const maximumFractionDigits = (_b = fractionDigits === null || fractionDigits === void 0 ? void 0 : fractionDigits.max) !== null && _b !== void 0 ? _b : fractionDigits;
const formatter = new Intl.NumberFormat('en-US', {
minimumFractionDigits,
maximumFractionDigits,
// 禁用自动分组: "12220%" => "12,220%"
useGrouping: false,
style: 'percent',
});
return formatter.format(value);
};
// ========================= 条件格式柱图相关 ==============================
/**
* 绘制单元格内的 条件格式 柱图
*/
export const drawInterval = (cell) => {
var _a, _b, _c, _d, _e, _f, _g, _h;
if (isEmpty(cell)) {
return;
}
const { x, y, height, width } = cell.getBBoxByType(CellClipBox.PADDING_BOX);
const intervalCondition = cell.findFieldCondition((_a = cell.cellConditions) === null || _a === void 0 ? void 0 : _a.interval);
if (intervalCondition === null || intervalCondition === void 0 ? void 0 : intervalCondition.mapping) {
const attrs = cell.mappingValue(intervalCondition);
if (!attrs) {
return;
}
const defaultValueRange = cell.getValueRange();
const valueRange = attrs.isCompare ? attrs : defaultValueRange;
const minValue = parseNumberWithPrecision((_b = valueRange.minValue) !== null && _b !== void 0 ? _b : defaultValueRange.minValue);
const maxValue = parseNumberWithPrecision((_c = valueRange.maxValue) !== null && _c !== void 0 ? _c : defaultValueRange.maxValue);
const fieldValue = isNil(attrs === null || attrs === void 0 ? void 0 : attrs.fieldValue)
? parseNumberWithPrecision(cell.getMeta().fieldValue)
: parseNumberWithPrecision(attrs === null || attrs === void 0 ? void 0 : attrs.fieldValue);
// 对于超出设定范围的值不予显示
if (fieldValue < minValue || fieldValue > maxValue) {
return;
}
const cellStyle = cell.getStyle();
const barChartHeight = (_e = (_d = cellStyle === null || cellStyle === void 0 ? void 0 : cellStyle.miniChart) === null || _d === void 0 ? void 0 : _d.interval) === null || _e === void 0 ? void 0 : _e.height;
const barChartFillColor = (_g = (_f = cellStyle === null || cellStyle === void 0 ? void 0 : cellStyle.miniChart) === null || _f === void 0 ? void 0 : _f.interval) === null || _g === void 0 ? void 0 : _g.fill;
const getScale = getIntervalScale(minValue, maxValue);
const { zeroScale, scale: intervalScale } = getScale(fieldValue);
const fill = (_h = attrs.fill) !== null && _h !== void 0 ? _h : barChartFillColor;
const style = {
x: x + width * zeroScale,
y: y + height / 2 - barChartHeight / 2,
width: width * intervalScale,
height: barChartHeight,
fill,
};
const conditionIntervalShape = cell.getConditionIntervalShape();
if (conditionIntervalShape) {
batchSetStyle(conditionIntervalShape, style);
return conditionIntervalShape;
}
return renderRect(cell, style);
}
};
/**
* 绘制单元格内的 mini 子弹图
*/
export const drawBullet = (value, cell) => {
const dataCellStyle = cell.getStyle(CellType.DATA_CELL);
const { x, y, height, width } = cell.getMeta();
if (isEmpty(value)) {
cell.renderTextShape(Object.assign(Object.assign({}, dataCellStyle.text), { x: x + width - dataCellStyle.cell.padding.right, y: y + height / 2, text: '' }));
return;
}
const bulletStyle = dataCellStyle.miniChart.bullet;
const { progressBar, comparativeMeasure, rangeColors, backgroundColor } = bulletStyle;
const { measure, target } = value;
const displayMeasure = Math.max(Number(measure), 0);
const displayTarget = Math.max(Number(target), 0);
/*
* 原本是 "0%", 需要精确到浮点数后两位, 保证数值很小时能正常显示, 显示的百分比格式为 "0.22%"
* 所以子弹图需要为数值预留宽度
* 对于负数, 进度条计算按照 0 处理, 但是展示还是要显示原来的百分比
*/
const measurePercent = transformRatioToPercent(measure, 2);
const widthPercent = (progressBar === null || progressBar === void 0 ? void 0 : progressBar.widthPercent) > 1
? (progressBar === null || progressBar === void 0 ? void 0 : progressBar.widthPercent) / 100
: progressBar === null || progressBar === void 0 ? void 0 : progressBar.widthPercent;
const padding = dataCellStyle.cell.padding;
const contentWidth = width - padding.left - padding.right;
// 子弹图先占位 (bulletWidth),剩下空间给文字 (measureWidth)
const bulletWidth = widthPercent * contentWidth;
const measureWidth = contentWidth - bulletWidth;
/**
* 绘制子弹图 (右对齐)
* 1. 背景
*/
const positionX = x + width - padding.right - bulletWidth;
const positionY = y + height / 2 - progressBar.height / 2;
renderRect(cell, {
x: positionX,
y: positionY,
width: bulletWidth,
height: progressBar.height,
fill: backgroundColor,
});
// 2. 进度条
const displayBulletWidth = Math.max(Math.min(bulletWidth * displayMeasure, bulletWidth), 0);
renderRect(cell, {
x: positionX,
y: positionY + (progressBar.height - progressBar.innerHeight) / 2,
width: displayBulletWidth,
height: progressBar.innerHeight,
fill: getBulletRangeColor(displayMeasure, displayTarget, rangeColors),
});
// 3.测量标记线
const lineX = positionX + bulletWidth * displayTarget;
renderLine(cell, {
x1: lineX,
y1: y + (height - comparativeMeasure.height) / 2,
x2: lineX,
y2: y + (height - comparativeMeasure.height) / 2 + comparativeMeasure.height,
stroke: comparativeMeasure === null || comparativeMeasure === void 0 ? void 0 : comparativeMeasure.fill,
lineWidth: comparativeMeasure.width,
opacity: comparativeMeasure === null || comparativeMeasure === void 0 ? void 0 : comparativeMeasure.opacity,
});
// 4.绘制指标
const maxTextWidth = measureWidth - padding.right;
cell.renderTextShape(Object.assign(Object.assign({}, dataCellStyle.text), { x: positionX - padding.right, y: y + height / 2, text: measurePercent, wordWrapWidth: maxTextWidth }));
};
export const renderMiniChart = (cell, data) => {
switch (data === null || data === void 0 ? void 0 : data.type) {
case MiniChartType.Line:
drawLine(data, cell);
break;
case MiniChartType.Bar:
drawBar(data, cell);
break;
default:
drawBullet(data, cell);
break;
}
};
//# sourceMappingURL=g-mini-charts.js.map