/* * 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'; import { warn } from '../../../util/log'; /* 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 = {}; 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): 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 { private _dom: HTMLDivElement; onclick(ecModel: GlobalModel, api: ExtensionAPI) { // FIXME: better way? setTimeout(() => { api.dispatchAction({ type: 'hideTip' }); }); const container = api.getDom(); const model = this.model; if (this._dom) { container.removeChild(this._dom); } const root = document.createElement('div'); // use padding to avoid 5px whitespace root.style.cssText = 'position:absolute;top:0;bottom:0;left:0;right:0;padding: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 = 'overflow:auto'; const optionToContent = model.get('optionToContent'); const contentToOption = model.get('contentToOption'); const result = getContentFromModel(ecModel); if (zrUtil.isFunction(optionToContent)) { const htmlOrDom = optionToContent(api.getOption()); if (zrUtil.isString(htmlOrDom)) { viewMain.innerHTML = htmlOrDom; } else if (zrUtil.isDom(htmlOrDom)) { viewMain.appendChild(htmlOrDom); } } else { // Use default textarea textarea.readOnly = model.get('readOnly'); const style = textarea.style; // eslint-disable-next-line max-len style.cssText = 'display:block;width:100%;height:100%;font-family:monospace;font-size:14px;line-height:1.6rem;resize:none;box-sizing:border-box;outline:none'; style.color = model.get('textColor'); style.borderColor = model.get('textareaBorderColor'); style.backgroundColor = model.get('textareaColor'); textarea.value = result.value; viewMain.appendChild(textarea); } const blockMetaList = result.meta; const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = 'position:absolute;bottom:5px;left:0;right:0'; // eslint-disable-next-line max-len 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 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 (zrUtil.isFunction(contentToOption)) { 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 = 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;