| <!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> | |