| <!DOCTYPE html> |
| <!-- |
| 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. |
| --> |
| |
| |
| <html> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <script src="lib/simpleRequire.js"></script> |
| <script src="lib/config.js"></script> |
| <script src="lib/jquery.min.js"></script> |
| <script src="lib/facePrint.js"></script> |
| <script src="lib/testHelper.js"></script> |
| <!-- <script src="ut/lib/canteen.js"></script> --> |
| <link rel="stylesheet" href="lib/reset.css" /> |
| </head> |
| <body> |
| <style> |
| </style> |
| |
| |
| <div id="main_spreadsheet_mimic"></div> |
| |
| |
| <script> |
| require([ |
| 'echarts', |
| ], function (echarts) { |
| |
| initChart(); |
| |
| function initChart() { |
| |
| const headerItemStyle = { |
| borderWidth: 1, |
| color: 'rgb(25,25,25)', |
| borderColor: 'rgb(64,64,64)', |
| }; |
| const cornerItemStyle = { |
| // borderWidth: 1, |
| color: 'rgb(25,25,25)', |
| borderColor: 'rgb(25,25,25)', |
| }; |
| const headerLabelOption = { |
| color: '#eee', |
| }; |
| const dividerLineStyle = { |
| width: 0, |
| }; |
| const bodyLabelOption = { |
| // overflow: 'break', |
| // width: 'auto', |
| }; |
| |
| const _matrixXYData = { |
| x: [{ |
| value: 'R', |
| children: ['R1', {value: 'R2'}] |
| }, { |
| value: 'S' |
| }, { |
| value: 'T', |
| children: [{ |
| value: 'T1', |
| children: ['T11'] |
| }] |
| }, { |
| value: 'U' |
| }, { |
| value: 'V' |
| }, { |
| value: 'W' |
| }], |
| |
| y: [{ |
| value: 'LL', |
| children: [{ |
| value: 'L', |
| children: [{ |
| value: 'A', |
| children: ['A1', 'A2', { |
| value: 'A3', |
| children: ['A31', 'A32'] |
| }] |
| }, { |
| value: 'B', |
| children: ['B1', 'B2', 'B3', 'B4'] |
| }, { |
| value: 'C', |
| }, { |
| value: 'D', |
| }] |
| }, { |
| value: 'E', |
| }] |
| }] |
| }; |
| |
| const _matrixBodyData = [ |
| {coord: [[1, 2], [4, 5]], mergeCells: true, value: 'Firecracker Chicken Bites'}, |
| {coord: [[2, 4], [1, 2]], mergeCells: true, value: 'Velvet Butter Curry'}, |
| {coord: [1, 7], value: 'Ginger Blaze Wings'}, |
| {coord: [3, [7, 8]], value: 'Sun-Dried Tomato Twists'}, |
| ]; |
| const _matrixCornerData = [ |
| {coord: [[-1, -5], -1], mergeCells: true, value: 'Y header'}, |
| {coord: [[-1, -3], [-2, -3]], mergeCells: true, value: 'X header'}, |
| ]; |
| |
| const option = { |
| backgroundColor: '#eee', |
| title: { |
| text: 'Double click to edit a cell', |
| right: 10, |
| textStyle: { |
| fontSize: 12, |
| // fontWeight: 'normal', |
| } |
| }, |
| tooltip: {}, |
| matrix: { |
| left: 150, |
| right: 30, |
| x: { |
| levelSize: 20, |
| cursor: 's-resize', |
| silent: false, |
| itemStyle: headerItemStyle, |
| label: headerLabelOption, |
| dividerLineStyle, |
| data: _matrixXYData.x, |
| }, |
| y: { |
| levelSize: 25, |
| cursor: 'e-resize', |
| silent: false, |
| itemStyle: headerItemStyle, |
| label: headerLabelOption, |
| dividerLineStyle, |
| data: _matrixXYData.y, |
| }, |
| body: { |
| cursor: 'cell', |
| silent: false, |
| itemStyle: { |
| color: '#fff', |
| borderColor: 'rgb(194,202,220)' |
| }, |
| label: bodyLabelOption, |
| data: _matrixBodyData, |
| }, |
| corner: { |
| cursor: 'cell', |
| silent: false, |
| itemStyle: cornerItemStyle, |
| label: headerLabelOption, |
| data: _matrixCornerData, |
| }, |
| backgroundStyle: { |
| borderWidth: 2, |
| }, |
| }, |
| graphic: { |
| z: 100, |
| elements: [{ |
| type: 'rect', |
| id: 'selection_rect', |
| shape: {x: 0, y: 0, width: 0, height: 0}, |
| ignore: true, |
| silent: true, |
| style: { |
| lineWidth: 3, |
| fill: 'rgba(0,180,230,0.2)', |
| stroke: 'rgba(0,100,230,1)' |
| } |
| }, { |
| type: 'text', |
| left: 10, |
| top: 10, |
| id: 'selected_info', |
| silent: true, |
| style: { |
| text: '', |
| fontSize: 10, |
| } |
| }] |
| }, |
| }; |
| |
| var chart = testHelper.create(echarts, 'main_spreadsheet_mimic', { |
| title: [ |
| 'Mimic spreadsheet', |
| 'Test API: convertToLayout, convertToPixel, convertFromPixel', |
| '**Brush** start from body and header to perform different types of rect-selection.', |
| 'Move outside the matrix area when brushing to test **clamp**', |
| 'Mouse cursor should be different on body and header.', |
| 'When brush start from body, selection rect should cover the entire merged cells.', |
| 'When brush start from header, non-leaf should cover the entire span.', |
| ], |
| height: 500, |
| option: option, |
| buttons: [ |
| { |
| text: 'hide/show matrix.x', |
| onclick() { |
| _ctx && _ctx.showHideXDimension(); |
| } |
| }, |
| { |
| text: 'hide/show matrix.y', |
| onclick() { |
| _ctx && _ctx.showHideYDimension(); |
| } |
| }, |
| { |
| text: 'merge cells', |
| onclick() { |
| _ctx && _ctx.mergeSelectedCells(); |
| } |
| }, |
| { |
| text: 'add a sample cartesian', |
| onclick() { |
| _ctx && _ctx.addSampleCartesian(); |
| } |
| } |
| ] |
| }); |
| |
| const _ctx = miniSpreadsheet(chart, _matrixBodyData); |
| |
| } // End of initChart |
| |
| |
| function miniSpreadsheet(chart, _rawMatrixBodyData) { |
| if (!chart) { |
| return; |
| } |
| |
| const XY = ['x', 'y']; |
| const WH = ['width', 'height']; |
| const MatrixClampOption = {all: 1, body: 2, corner: 3}; |
| const MatrixBrushMode = {body: 2, corner: 3, header: 4}; |
| |
| /** |
| * @typedef {number[][]} MatrixXYLocatorRange |
| * @typedef { |
| * { |
| * rawOption: MatrixOption['body']['data'][number]; |
| * xyLocatorRange: MatrixXYLocatorRange; |
| * } |
| * } ParsedMatrixBodyDataItem |
| */ |
| |
| |
| function enableChangeNotify(instance) { |
| const _listeners = []; |
| instance.onChange = function (listener) { |
| _listeners.push(listener); |
| }; |
| instance._notifyChange = function () { |
| for (let listener of _listeners) { |
| listener(); |
| } |
| } |
| } |
| |
| |
| class MatrixDataWrapper { |
| constructor(chart, selection) { |
| this._selection = selection; |
| /** |
| * @readonly |
| * @type {MatrixXYLocatorRange} |
| */ |
| this.overallLocatorRange = null; |
| /** |
| * @readonly |
| * @type {ParsedMatrixBodyDataItem[]} |
| */ |
| this.parsedBodyData = null; |
| |
| enableChangeNotify(this); |
| |
| selection.onChange(() => { |
| this._updateSelected(); |
| }); |
| } |
| |
| init(rawMatrixBodyData) { |
| const overallLocatorRange = chart.convertToLayout( |
| {matrixIndex: 0}, [NaN, NaN], {clamp: MatrixClampOption.all} |
| ).matrixXYLocatorRange; |
| |
| const parsedBodyData = []; |
| rawMatrixBodyData.forEach(rawOption => { |
| if (!rawOption || !rawOption.coord) { |
| return; |
| } |
| const layout = chart.convertToLayout({matrixIndex: 0}, rawOption.coord); |
| const xyLocatorRange = layout.matrixXYLocatorRange; |
| if (isFinite(xyLocatorRange[0][0]) && isFinite(xyLocatorRange[0][1]) |
| && isFinite(xyLocatorRange[1][0]) && isFinite(xyLocatorRange[1][1]) |
| ) { |
| parsedBodyData.push({xyLocatorRange, rawOption}); |
| } |
| }); |
| |
| this.overallLocatorRange = overallLocatorRange; |
| this.parsedBodyData = parsedBodyData; |
| } |
| |
| addDataItem(xyLocatorRange, txt, mergeCells) { |
| const coord = [xyLocatorRange[0].slice(), xyLocatorRange[1].slice()]; |
| this.parsedBodyData.push({ |
| xyLocatorRange: coord, |
| rawOption: {coord, value: txt, mergeCells} |
| }); |
| } |
| |
| _updateSelected() { |
| const xyLocatorRange = this._selection.xyLocatorRange; |
| if (!xyLocatorRange) { |
| this.selectedBodyData = []; |
| return; |
| } |
| this.selectedBodyData = this.parsedBodyData.filter( |
| item => xyLocatorRangeIntersect(item.xyLocatorRange, xyLocatorRange) |
| ); |
| |
| this._notifyChange(); |
| } |
| } // End of MatrixDataWrapper |
| |
| |
| /** |
| * @param {MatrixXYLocatorRange} locRange1 |
| * @param {MatrixXYLocatorRange} locRange2 |
| * @return boolean |
| */ |
| function xyLocatorRangeIntersect(locRange1, locRange2) { |
| return intersectOnOneDimension(locRange1[0], locRange2[0]) |
| && intersectOnOneDimension(locRange1[1], locRange2[1]); |
| |
| function intersectOnOneDimension(oneDimLocRange1, oneDimLocRange2) { |
| return oneDimLocRange1[1] >= oneDimLocRange2[0] |
| && oneDimLocRange1[0] <= oneDimLocRange2[1]; |
| } |
| } |
| |
| class Selection { |
| constructor(chart) { |
| this._chart = chart; |
| this._brushing = false; |
| /** |
| * @typedef {{ |
| * clampOpt: {clamp: typeof MatrixClampOption}; |
| * brushMode: typeof MatrixBrushMode; |
| * startXYLocator: null; |
| * endXYLocator: null; |
| * headerBrushDimIdx: 0 | 1; // x is 0, y is 1. |
| * }} SelectionCtx |
| * |
| * If no selected, be null | undefined. |
| * @type {SelectionCtx | null | undefined} |
| */ |
| this._ctx = null; |
| /** |
| * If no selected, be null | undefined. |
| * @type {RectLike | null | undefined} |
| * @readonly |
| */ |
| this.rect = null; |
| /** |
| * If no selected, be null | undefined. |
| * @type {XYLocatorRange | null | undefined} |
| * @readonly |
| */ |
| this.xyLocatorRange = null; |
| |
| enableChangeNotify(this); |
| } |
| |
| _calcSelectionRect() { |
| if (!this._ctx) { |
| return; |
| } |
| const {startXYLocator, endXYLocator, headerBrushDimIdx} = this._ctx; |
| const otherDimIdx = 1 - headerBrushDimIdx; |
| |
| let locatorRangeInput; |
| let ignoreMergeCells; |
| if (this._ctx.brushMode === MatrixBrushMode.body |
| || this._ctx.brushMode === MatrixBrushMode.corner |
| ) { |
| locatorRangeInput = [ |
| [startXYLocator[0], endXYLocator[0]], |
| [startXYLocator[1], endXYLocator[1]] |
| ]; |
| ignoreMergeCells = false; |
| } |
| else if (this._ctx.brushMode === MatrixBrushMode.header) { |
| // Find the dimension header cell, may be non-leaf, |
| // covering multiple rows/columns. |
| locatorRangeInput = [[], []]; |
| locatorRangeInput[headerBrushDimIdx][0] = startXYLocator[headerBrushDimIdx]; |
| locatorRangeInput[headerBrushDimIdx][1] = endXYLocator[headerBrushDimIdx]; |
| locatorRangeInput[otherDimIdx][0] = _matrixDataWrapper.overallLocatorRange[otherDimIdx][0]; |
| locatorRangeInput[otherDimIdx][1] = _matrixDataWrapper.overallLocatorRange[otherDimIdx][1]; |
| ignoreMergeCells = true; |
| } |
| |
| const layout = this._chart.convertToLayout( |
| {matrixIndex: 0}, |
| locatorRangeInput, |
| {clamp: this._ctx.clampOpt, ignoreMergeCells} |
| ); |
| |
| if (isFinite(layout.rect.x) && isFinite(layout.rect.y) |
| && isFinite(layout.rect.width) && isFinite(layout.rect.height) |
| ) { |
| this.rect = layout.rect; |
| this.xyLocatorRange = layout.matrixXYLocatorRange; |
| } |
| else { |
| this.rect = this.xyLocatorRange = null; |
| } |
| } |
| |
| brushStart(point) { |
| this.clearSelected(); |
| this._brushing = true; |
| |
| const startXYLocator = chart.convertFromPixel({matrixIndex: 0}, point); |
| if (isNaN(startXYLocator[0]) || isNaN(startXYLocator[1])) { |
| this._brushing = false; |
| return; // Out of matrix area. |
| } |
| |
| let clampOpt; |
| let brushMode; |
| let headerBrushDimIdx; |
| |
| if (startXYLocator[0] < 0 && startXYLocator[1] < 0) { // In corner area. |
| clampOpt = {clamp: MatrixClampOption.corner}; |
| brushMode = MatrixBrushMode.corner; |
| } |
| else if (startXYLocator[0] >= 0 && startXYLocator[1] >= 0) { // In body area. |
| clampOpt = {clamp: MatrixClampOption.body}; |
| brushMode = MatrixBrushMode.body; |
| } |
| else { // In dimension (header) area. |
| clampOpt = {clamp: MatrixClampOption.body}; |
| brushMode = MatrixBrushMode.header; |
| headerBrushDimIdx = startXYLocator[0] >= 0 ? 0 : 1; |
| } |
| |
| this._ctx = { |
| startXYLocator, |
| clampOpt, |
| brushMode, |
| headerBrushDimIdx, |
| }; |
| } |
| |
| brushUpdate(point) { |
| if (!this._brushing) { |
| return; |
| } |
| const xyLocator = chart.convertFromPixel({matrixIndex: 0}, point, this._ctx.clampOpt) |
| this._ctx.endXYLocator = xyLocator; |
| |
| this._calcSelectionRect(); |
| |
| this._notifyChange(); |
| } |
| |
| getBrushMode() { |
| return this._ctx ? this._ctx.brushMode : null; |
| } |
| |
| brushEnd() { |
| this._brushing = false; |
| } |
| |
| resize() { |
| this._calcSelectionRect(); |
| |
| this._notifyChange(); |
| } |
| |
| clearSelected() { |
| this._ctx = null; |
| this.xyLocatorRange = null; |
| this.rect = null; |
| } |
| |
| } // End of Selection |
| |
| |
| class CellInput { |
| constructor(chart, selection, matrixDataWrapper) { |
| this._chart = chart; |
| this._selection = selection; |
| this._matrixDataWrapper = matrixDataWrapper; |
| } |
| |
| init() { |
| this._input = document.createElement('div'); |
| document.body.appendChild(this._input); |
| this._input.style.cssText = [ |
| 'display: none;', |
| 'position: absolute;', |
| 'border: 0 solid #fff;', |
| 'background: #fff;', |
| 'box-sizing: border-box;', |
| 'font-size: 12px;', |
| 'margin: 0;', |
| 'padding: 0 2px;', |
| 'white-space: nowrap;', |
| 'overflow-x: hidden;', |
| 'overflow-y: hidden;', |
| ].join(''); |
| this._input.setAttribute('contenteditable', 'true'); |
| this._inputing = false; |
| } |
| |
| start() { |
| if (!this._selection.rect) { |
| return; |
| } |
| const chartRect = chart.getDom().getBoundingClientRect(); |
| const x = chartRect.x + this._selection.rect.x + 2; |
| const y = chartRect.y + this._selection.rect.y + 2; |
| const width = this._selection.rect.width - 4; |
| const height = this._selection.rect.height - 4; |
| |
| this._inputing = true; |
| this._input.innerHTML = ''; |
| this._input.style.display = 'block'; |
| this._input.style.width = width + 'px'; |
| this._input.style.height = height + 'px'; |
| this._input.style.lineHeight = height + 'px'; |
| this._input.style.left = x + 'px'; |
| this._input.style.top = y + 'px'; |
| |
| this._input.focus(); |
| } |
| |
| finish() { |
| if (!this._inputing) { |
| return; |
| } |
| this._inputing = false; |
| const txt = this._input.innerText; |
| this._input.style.display = 'none'; |
| |
| const xyLocatorRange = this._selection.xyLocatorRange; |
| if (!xyLocatorRange || !txt) { |
| return; |
| } |
| this._matrixDataWrapper.addDataItem(xyLocatorRange, txt, false); |
| |
| this._chart.setOption({ |
| matrix: { |
| body: { |
| data: _matrixDataWrapper.parsedBodyData.map( |
| item => item.rawOption |
| ) |
| } |
| }, |
| }); |
| } |
| } // End of CellInput |
| |
| |
| class SelectionView { |
| constructor(chart, matrixDataWrapper, selection) { |
| this._chart = chart; |
| this._viewDirty = false; |
| this._matrixDataWrapper = matrixDataWrapper; |
| this._selection = selection; |
| |
| const dirty = () => { |
| this._viewDirty = true; |
| } |
| |
| selection.onChange(dirty) |
| matrixDataWrapper.onChange(dirty); |
| } |
| |
| updateView() { |
| if (!this._viewDirty) { |
| return; |
| } |
| this._viewDirty = false; |
| |
| const selectionRect = _selection.rect; |
| |
| this._chart.setOption({ |
| graphic: { |
| elements: [{ |
| id: 'selection_rect', |
| ignore: !selectionRect, |
| shape: selectionRect |
| ? { |
| x: selectionRect.x, |
| y: selectionRect.y, |
| width: selectionRect.width, |
| height: selectionRect.height, |
| } |
| : {x: 0, y: 0, width: 0, height: 0}, |
| }, { |
| id: 'selected_info', |
| style: { |
| text: 'Selected values:\n' |
| + _matrixDataWrapper.selectedBodyData.map( |
| item => item.rawOption.value |
| ).join('\n') |
| } |
| }] |
| }, |
| }); |
| } |
| } // End of SelectionView |
| |
| |
| function showHideXDimension() { |
| _xShowing = !_xShowing; |
| // Render directly, since `chart.converToLayout` depends on this result. |
| chart.setOption({ |
| matrix: {x: {show: _xShowing}}, |
| }); |
| |
| _selection.resize(); |
| _selectionView.updateView(); |
| } |
| |
| function showHideYDimension() { |
| _yShowing = !_yShowing; |
| // Render directly, since `chart.converToLayout` depends on this result. |
| chart.setOption({ |
| matrix: {y: {show: _yShowing}} |
| }); |
| |
| _selection.resize(); |
| _selectionView.updateView(); |
| } |
| |
| function prepareCoordForBodySelectionRect() { |
| const xyLocatorRange = _selection.xyLocatorRange; |
| if (!xyLocatorRange) { |
| alert('Need to brush a rect first.'); |
| return; |
| } |
| const brushMode = _selection.getBrushMode(); |
| if (brushMode !== MatrixBrushMode.body && brushMode !== MatrixBrushMode.header) { |
| alert('Only selected body cells can be merged.'); |
| return; |
| } |
| return [xyLocatorRange[0].slice(), xyLocatorRange[1].slice()]; |
| } |
| |
| function mergeSelectedCells() { |
| const coord = prepareCoordForBodySelectionRect(); |
| if (!coord) { |
| return; |
| } |
| _matrixDataWrapper.addDataItem(coord, undefined, true); |
| chart.setOption({ |
| matrix: { |
| body: { |
| data: _matrixDataWrapper.parsedBodyData.map( |
| item => item.rawOption |
| ) |
| } |
| }, |
| }); |
| } |
| |
| function addSampleCartesian() { |
| const coord = prepareCoordForBodySelectionRect(); |
| if (!coord) { |
| return; |
| } |
| const id = _sampleCartesianIdBase++; |
| const categoryData = ['Mon', 'Tue', 'Web', 'Thu', 'Fri']; |
| const seriesData = categoryData.map(name => { |
| return Math.round(Math.random() * 100); |
| }); |
| const gridId = `grid_${id}`; |
| const xAxisId = `xAxis_${id}`; |
| const yAxisId = `yAxis_${id}`; |
| const seriesId = `series.line_${id}`; |
| chart.setOption({ |
| grid: { |
| id: gridId, |
| coordinateSystem: 'matrix', |
| coord, |
| top: 2, |
| left: 2, |
| right: 2, |
| bottom: 2, |
| containLabel: true, |
| }, |
| xAxis: { |
| id: xAxisId, |
| gridId, |
| data: ['Mon', 'Tue', 'Web', 'Thu', 'Fri'] |
| }, |
| yAxis: { |
| id: yAxisId, |
| gridId, |
| }, |
| series: { |
| type: 'line', |
| id: seriesId, |
| xAxisId, |
| yAxisId, |
| data: seriesData, |
| } |
| }); |
| } |
| |
| let _xShowing = true; |
| let _yShowing = true; |
| let _sampleCartesianIdBase = 100; |
| |
| const _zr = chart.getZr(); |
| |
| const _selection = new Selection(chart); |
| const _matrixDataWrapper = new MatrixDataWrapper(chart, _selection); |
| const _cellInput = new CellInput(chart, _selection, _matrixDataWrapper); |
| const _selectionView = new SelectionView(chart, _matrixDataWrapper, _selection); |
| |
| _matrixDataWrapper.init(_rawMatrixBodyData); |
| _cellInput.init(); |
| |
| |
| _zr.on('mousedown', function (event) { |
| _cellInput.finish(); |
| _selection.clearSelected(); |
| _selection.brushStart([event.offsetX, event.offsetY]); |
| _selection.brushUpdate([event.offsetX, event.offsetY]); |
| _selectionView.updateView(); |
| }); |
| _zr.on('mousemove', function (event) { |
| _selection.brushUpdate([event.offsetX, event.offsetY]); |
| _selectionView.updateView(); |
| }); |
| _zr.on('mouseup', function (event) { |
| _selection.brushUpdate([event.offsetX, event.offsetY]); |
| _selection.brushEnd(); |
| _selectionView.updateView(); |
| }); |
| _zr.on('dblclick', function (event) { |
| _cellInput.start(); |
| }); |
| |
| window.addEventListener('resize', function () { |
| _selectionView.updateView(); |
| }, false); |
| |
| return { |
| showHideXDimension, |
| showHideYDimension, |
| mergeSelectedCells, |
| addSampleCartesian, |
| }; |
| |
| } // End of miniSpreadsheet |
| |
| }); |
| |
| </script> |
| |
| |
| |
| |
| |
| |
| </body> |
| </html> |
| |