blob: 6256705e182f309b9074d825a4908546b81ac745 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* global document */
import * as echarts from '../../../core/echarts';
import * as zrUtil from 'zrender/src/core/util';
import GlobalModel from '../../../model/Global';
import SeriesModel from '../../../model/Series';
import { ToolboxFeature, ToolboxFeatureOption } from '../featureManager';
import { ColorString, ECUnitOption, SeriesOption, Payload, Dictionary } from '../../../util/types';
import ExtensionAPI from '../../../core/ExtensionAPI';
import { addEventListener } from 'zrender/src/core/event';
import Axis from '../../../coord/Axis';
import Cartesian2D from '../../../coord/cartesian/Cartesian2D';
/* global document */
const BLOCK_SPLITER = new Array(60).join('-');
const ITEM_SPLITER = '\t';
type DataItem = {
name: string
value: number[] | number
};
type DataList = (DataItem | number | number[])[];
interface ChangeDataViewPayload extends Payload {
newOption: {
series: SeriesOption[]
}
}
interface SeriesGroupMeta {
axisDim: string
axisIndex: number
}
interface SeriesGroup {
series: SeriesModel[]
categoryAxis: Axis
valueAxis: Axis
}
/**
* Group series into two types
* 1. on category axis, like line, bar
* 2. others, like scatter, pie
*/
function groupSeries(ecModel: GlobalModel) {
const seriesGroupByCategoryAxis: Dictionary<SeriesGroup> = {};
const otherSeries: SeriesModel[] = [];
const meta: SeriesGroupMeta[] = [];
ecModel.eachRawSeries(function (seriesModel) {
const coordSys = seriesModel.coordinateSystem;
if (coordSys && (coordSys.type === 'cartesian2d' || coordSys.type === 'polar')) {
// TODO: TYPE Consider polar? Include polar may increase unecessary bundle size.
const baseAxis = (coordSys as Cartesian2D).getBaseAxis();
if (baseAxis.type === 'category') {
const key = baseAxis.dim + '_' + baseAxis.index;
if (!seriesGroupByCategoryAxis[key]) {
seriesGroupByCategoryAxis[key] = {
categoryAxis: baseAxis,
valueAxis: coordSys.getOtherAxis(baseAxis),
series: []
};
meta.push({
axisDim: baseAxis.dim,
axisIndex: baseAxis.index
});
}
seriesGroupByCategoryAxis[key].series.push(seriesModel);
}
else {
otherSeries.push(seriesModel);
}
}
else {
otherSeries.push(seriesModel);
}
});
return {
seriesGroupByCategoryAxis: seriesGroupByCategoryAxis,
other: otherSeries,
meta: meta
};
}
/**
* Assemble content of series on cateogory axis
* @inner
*/
function assembleSeriesWithCategoryAxis(groups: Dictionary<SeriesGroup>): string {
const tables: string[] = [];
zrUtil.each(groups, function (group, key) {
const categoryAxis = group.categoryAxis;
const valueAxis = group.valueAxis;
const valueAxisDim = valueAxis.dim;
const headers = [' '].concat(zrUtil.map(group.series, function (series) {
return series.name;
}));
// @ts-ignore TODO Polar
const columns = [categoryAxis.model.getCategories()];
zrUtil.each(group.series, function (series) {
const rawData = series.getRawData();
columns.push(series.getRawData().mapArray(rawData.mapDimension(valueAxisDim), function (val) {
return val;
}));
});
// Assemble table content
const lines = [headers.join(ITEM_SPLITER)];
for (let i = 0; i < columns[0].length; i++) {
const items = [];
for (let j = 0; j < columns.length; j++) {
items.push(columns[j][i]);
}
lines.push(items.join(ITEM_SPLITER));
}
tables.push(lines.join('\n'));
});
return tables.join('\n\n' + BLOCK_SPLITER + '\n\n');
}
/**
* Assemble content of other series
*/
function assembleOtherSeries(series: SeriesModel[]) {
return zrUtil.map(series, function (series) {
const data = series.getRawData();
const lines = [series.name];
const vals: string[] = [];
data.each(data.dimensions, function () {
const argLen = arguments.length;
const dataIndex = arguments[argLen - 1];
const name = data.getName(dataIndex);
for (let i = 0; i < argLen - 1; i++) {
vals[i] = arguments[i];
}
lines.push((name ? (name + ITEM_SPLITER) : '') + vals.join(ITEM_SPLITER));
});
return lines.join('\n');
}).join('\n\n' + BLOCK_SPLITER + '\n\n');
}
function getContentFromModel(ecModel: GlobalModel) {
const result = groupSeries(ecModel);
return {
value: zrUtil.filter([
assembleSeriesWithCategoryAxis(result.seriesGroupByCategoryAxis),
assembleOtherSeries(result.other)
], function (str) {
return !!str.replace(/[\n\t\s]/g, '');
}).join('\n\n' + BLOCK_SPLITER + '\n\n'),
meta: result.meta
};
}
function trim(str: string) {
return str.replace(/^\s\s*/, '').replace(/\s\s*$/, '');
}
/**
* If a block is tsv format
*/
function isTSVFormat(block: string): boolean {
// Simple method to find out if a block is tsv format
const firstLine = block.slice(0, block.indexOf('\n'));
if (firstLine.indexOf(ITEM_SPLITER) >= 0) {
return true;
}
}
const itemSplitRegex = new RegExp('[' + ITEM_SPLITER + ']+', 'g');
/**
* @param {string} tsv
* @return {Object}
*/
function parseTSVContents(tsv: string) {
const tsvLines = tsv.split(/\n+/g);
const headers = trim(tsvLines.shift()).split(itemSplitRegex);
const categories: string[] = [];
const series: {name: string, data: string[]}[] = zrUtil.map(headers, function (header) {
return {
name: header,
data: []
};
});
for (let i = 0; i < tsvLines.length; i++) {
const items = trim(tsvLines[i]).split(itemSplitRegex);
categories.push(items.shift());
for (let j = 0; j < items.length; j++) {
series[j] && (series[j].data[i] = items[j]);
}
}
return {
series: series,
categories: categories
};
}
function parseListContents(str: string) {
const lines = str.split(/\n+/g);
const seriesName = trim(lines.shift());
const data: DataList = [];
for (let i = 0; i < lines.length; i++) {
// if line is empty, ignore it.
// there is a case that a user forgot to delete `\n`.
const line = trim(lines[i]);
if (!line) {
continue;
}
let items = line.split(itemSplitRegex);
let name = '';
let value: number[];
let hasName = false;
if (isNaN(items[0] as unknown as number)) { // First item is name
hasName = true;
name = items[0];
items = items.slice(1);
data[i] = {
name: name,
value: []
};
value = (data[i] as DataItem).value as number[];
}
else {
value = data[i] = [];
}
for (let j = 0; j < items.length; j++) {
value.push(+items[j]);
}
if (value.length === 1) {
hasName ? ((data[i] as DataItem).value = value[0]) : (data[i] = value[0]);
}
}
return {
name: seriesName,
data: data
};
}
function parseContents(str: string, blockMetaList: SeriesGroupMeta[]) {
const blocks = str.split(new RegExp('\n*' + BLOCK_SPLITER + '\n*', 'g'));
const newOption: ECUnitOption = {
series: []
};
zrUtil.each(blocks, function (block, idx) {
if (isTSVFormat(block)) {
const result = parseTSVContents(block);
const blockMeta = blockMetaList[idx];
const axisKey = blockMeta.axisDim + 'Axis';
if (blockMeta) {
newOption[axisKey] = newOption[axisKey] || [];
(newOption[axisKey] as any)[blockMeta.axisIndex] = {
data: result.categories
};
newOption.series = (newOption.series as SeriesOption[]).concat(result.series);
}
}
else {
const result = parseListContents(block);
(newOption.series as SeriesOption[]).push(result);
}
});
return newOption;
}
export interface ToolboxDataViewFeatureOption extends ToolboxFeatureOption {
readOnly?: boolean
optionToContent?: (option: ECUnitOption) => string | HTMLElement
contentToOption?: (viewMain: HTMLDivElement, oldOption: ECUnitOption) => ECUnitOption
icon?: string
title?: string
lang?: string[]
backgroundColor?: ColorString
textColor?: ColorString
textareaColor?: ColorString
textareaBorderColor?: ColorString
buttonColor?: ColorString
buttonTextColor?: ColorString
}
class DataView extends ToolboxFeature<ToolboxDataViewFeatureOption> {
private _dom: HTMLDivElement;
onclick(ecModel: GlobalModel, api: ExtensionAPI) {
const container = api.getDom();
const model = this.model;
if (this._dom) {
container.removeChild(this._dom);
}
const root = document.createElement('div');
root.style.cssText = 'position:absolute;left:5px;top:5px;bottom:5px;right:5px;';
root.style.backgroundColor = model.get('backgroundColor') || '#fff';
// Create elements
const header = document.createElement('h4');
const lang = model.get('lang') || [];
header.innerHTML = lang[0] || model.get('title');
header.style.cssText = 'margin: 10px 20px;';
header.style.color = model.get('textColor');
const viewMain = document.createElement('div');
const textarea = document.createElement('textarea');
viewMain.style.cssText = 'display:block;width:100%;overflow:auto;';
const optionToContent = model.get('optionToContent');
const contentToOption = model.get('contentToOption');
const result = getContentFromModel(ecModel);
if (typeof optionToContent === 'function') {
const htmlOrDom = optionToContent(api.getOption());
if (typeof htmlOrDom === 'string') {
viewMain.innerHTML = htmlOrDom;
}
else if (zrUtil.isDom(htmlOrDom)) {
viewMain.appendChild(htmlOrDom);
}
}
else {
// Use default textarea
viewMain.appendChild(textarea);
textarea.readOnly = model.get('readOnly');
textarea.style.cssText = 'width:100%;height:100%;font-family:monospace;font-size:14px;line-height:1.6rem;';
textarea.style.color = model.get('textColor');
textarea.style.borderColor = model.get('textareaBorderColor');
textarea.style.backgroundColor = model.get('textareaColor');
textarea.value = result.value;
}
const blockMetaList = result.meta;
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = 'position:absolute;bottom:0;left:0;right:0;';
let buttonStyle = 'float:right;margin-right:20px;border:none;'
+ 'cursor:pointer;padding:2px 5px;font-size:12px;border-radius:3px';
const closeButton = document.createElement('div');
const refreshButton = document.createElement('div');
buttonStyle += ';background-color:' + model.get('buttonColor');
buttonStyle += ';color:' + model.get('buttonTextColor');
const self = this;
function close() {
container.removeChild(root);
self._dom = null;
}
addEventListener(closeButton, 'click', close);
addEventListener(refreshButton, 'click', function () {
if ((contentToOption == null && optionToContent != null)
|| (contentToOption != null && optionToContent == null)) {
if (__DEV__) {
// eslint-disable-next-line
console.warn('It seems you have just provided one of `contentToOption` and `optionToContent` functions but missed the other one. Data change is ignored.')
}
close();
return;
}
let newOption;
try {
if (typeof contentToOption === 'function') {
newOption = contentToOption(viewMain, api.getOption());
}
else {
newOption = parseContents(textarea.value, blockMetaList);
}
}
catch (e) {
close();
throw new Error('Data view format error ' + e);
}
if (newOption) {
api.dispatchAction({
type: 'changeDataView',
newOption: newOption
});
}
close();
});
closeButton.innerHTML = lang[1];
refreshButton.innerHTML = lang[2];
refreshButton.style.cssText = buttonStyle;
closeButton.style.cssText = buttonStyle;
!model.get('readOnly') && buttonContainer.appendChild(refreshButton);
buttonContainer.appendChild(closeButton);
root.appendChild(header);
root.appendChild(viewMain);
root.appendChild(buttonContainer);
viewMain.style.height = (container.clientHeight - 80) + 'px';
container.appendChild(root);
this._dom = root;
}
remove(ecModel: GlobalModel, api: ExtensionAPI) {
this._dom && api.getDom().removeChild(this._dom);
}
dispose(ecModel: GlobalModel, api: ExtensionAPI) {
this.remove(ecModel, api);
}
static getDefaultOption(ecModel: GlobalModel) {
const defaultOption: ToolboxDataViewFeatureOption = {
show: true,
readOnly: false,
optionToContent: null,
contentToOption: null,
// eslint-disable-next-line
icon: 'M17.5,17.3H33 M17.5,17.3H33 M45.4,29.5h-28 M11.5,2v56H51V14.8L38.4,2H11.5z M38.4,2.2v12.7H51 M45.4,41.7h-28',
title: ecModel.getLocaleModel().get(['toolbox', 'dataView', 'title']),
lang: ecModel.getLocaleModel().get(['toolbox', 'dataView', 'lang']),
backgroundColor: '#fff',
textColor: '#000',
textareaColor: '#fff',
textareaBorderColor: '#333',
buttonColor: '#c23531',
buttonTextColor: '#fff'
};
return defaultOption;
}
}
/**
* @inner
*/
function tryMergeDataOption(newData: DataList, originalData: DataList) {
return zrUtil.map(newData, function (newVal, idx) {
const original = originalData && originalData[idx];
if (zrUtil.isObject(original) && !zrUtil.isArray(original)) {
const newValIsObject = zrUtil.isObject(newVal) && !zrUtil.isArray(newVal);
if (!newValIsObject) {
newVal = {
value: newVal
} as DataItem;
}
// original data has name but new data has no name
const shouldDeleteName = original.name != null && (newVal as DataItem).name == null;
// Original data has option
newVal = zrUtil.defaults((newVal as DataItem), original);
shouldDeleteName && (delete (newVal as DataItem).name);
return newVal;
}
else {
return newVal;
}
});
}
// TODO: SELF REGISTERED.
echarts.registerAction({
type: 'changeDataView',
event: 'dataViewChanged',
update: 'prepareAndUpdate'
}, function (payload: ChangeDataViewPayload, ecModel: GlobalModel) {
const newSeriesOptList: SeriesOption[] = [];
zrUtil.each(payload.newOption.series, function (seriesOpt) {
const seriesModel = ecModel.getSeriesByName(seriesOpt.name)[0];
if (!seriesModel) {
// New created series
// Geuss the series type
newSeriesOptList.push(zrUtil.extend({
// Default is scatter
type: 'scatter'
}, seriesOpt));
}
else {
const originalData = seriesModel.get('data');
newSeriesOptList.push({
name: seriesOpt.name,
data: tryMergeDataOption(seriesOpt.data as DataList, originalData as DataList)
});
}
});
ecModel.mergeOption(zrUtil.defaults({
series: newSeriesOptList
}, payload.newOption));
});
export default DataView;