// data preview vars let vscode, title, tableSelector, rowCounter, saveFileTypeSelector, dataViewConsoleText, dataUrlInput, dataUrl, dataFileName, viewer, theme, toggleConfig = true, restoringConfig = true, dataTable = '', dataViews = {}, tableNames = [], viewConfig = {}, viewData = [], logLevel = 'info'; // check web assembly support // console.log(`data.view:supportsWebAssembly(): ${supportsWebAssembly()}`); // initialize vs code api for messaging vscode = acquireVsCodeApi(); // add webview post message event handler window.addEventListener('message', event => { switch (event.data.command) { case 'dataInfo': loadDataInfo(event.data); break; case 'refresh': refresh(event.data); break; default: // raw byte array // convert byte array to typed data array for loading into data view viewData = Uint8Array.from(event.data); logMessage(`data size in bytes: ${viewData.byteLength.toLocaleString()}`); // update data viewer viewer.load(viewData.buffer); updateStats(); if (toggleConfig) { // show viewer toggles on the 1st run viewer.toggleConfig(); toggleConfig = false; toggleDataViewConsoleDisplay(); } break; } }); // wire document load state changes document.addEventListener('readystatechange', event => { switch (document.readyState) { case 'loading': logMessage(`loading: data url: ${dataUrl}`); break; case 'interactive': // document loading finished, images and stylesheets are still loading ... logMessage('interactive!'); // get initial data info: document url, views, etc. vscode.postMessage({command: 'getDataInfo'}); break; case "complete": // web page is fully loaded logMessage('document.readystatechange complete\n\n data.view:complete!'); break; } }); // tag viewer loading window.addEventListener('WebComponentsReady', event => { logMessage('window.WebComponentsReady\n\n data viewer initialized!'); }); // initialize data view on content loaded document.addEventListener('DOMContentLoaded', event => initializeDataView()); /** * Initializes data view UI elements, * updates view state, config, and * requests first data load for display. */ function initializeDataView() { // initialize toolbar & perspective data viewer logMessage('initializeDataView()\n\n initializing...'); title = document.getElementById('title'); dataViewConsoleText = document.getElementById('data-view-console-text'); tableSelector = document.getElementById('table-selector'); dataUrlInput = document.getElementById('data-url-input'); saveFileTypeSelector = document.getElementById('save-file-type-selector'); viewer = document.getElementsByTagName('perspective-viewer')[0]; try { // request first data update vscode.postMessage({command: 'refresh'}); // add view update handler viewer.addEventListener('perspective-view-update', event => { logMessage(`viewer.perspective-view-update...`); updateStats(); }); // add viewer config change handler for saving view state viewer.addEventListener('perspective-config-update', event => { if (!restoringConfig) { updateConfig(); } updateStats(); }); // add viewer click handler for cross-filtering viewer.addEventListener('perspective-click', event => { // TODO: experiment with this cross filter selection later // console.log(`data.view:dataClick(${dataTable}): detail=${event.detail.row} columns=${event.detail.column_names}`); }); } catch (error) { console.error(`data.view:vscode:error ${error.message}`); } } // end of initializeDataView() /** * Updates data stats on view config changes or data load/update. */ function updateStats() { const numberOfRows = viewer.view ? viewer.view.num_rows() : viewer.table.size(); // get rows count and displayed columns info numberOfRows.then(rowCount => { let columns = viewer['columns']; /*if (viewConfig.hasOwnProperty('columns')) { // use view config columns property instead columns = viewConfig['columns'].split('\",\"'); }*/ // notify webview for data stats status update vscode.postMessage({ command: 'stats', columns: columns, rowCount: rowCount }); logMessage(`updateStats()\n\n columns: ${columns}\n rows: ${rowCount.toLocaleString()}`); }); } // end of updateStats() /** * Loads initial data view info. * @param data Data info with data url, views, etc. */ function loadDataInfo(data) { // initialize data view vars dataUrl = data.uri; dataFileName = data.fileName; dataTable = data.table; dataViews = data.views; viewConfig = data.config; theme = data.theme; logLevel = data.logLevel; logMessage(`loadDataInfo()\n\n data url: ${dataUrl}\n data table: ${dataTable}`); // update view config restoreConfig(viewConfig); } /** * Restores data view config on new data view load or new view config load. * @param viewConfig Data view config to restore. */ function restoreConfig(viewConfig) { // set updating view config flag restoringConfig = true; // restore view config logMessage(`restoreConfig(\n${JSON.stringify(viewConfig, null, 2)})`); viewer.restore(viewConfig); //updateStats(); // clear updating view config flag restoringConfig = false; } /** * Updates view config caused by user interactions or new config load. */ function updateConfig() { // get latest view config state from data viewer const viewerConfig = viewer.save(); // strip out updating, render time and style properties for clean view config compare and save deleteProperty('updating', viewerConfig); deleteProperty('render_time', viewerConfig); deleteProperty('style', viewerConfig); deleteProperty('class', viewerConfig); // logMessage(`viewer.perspective-config-update\n${logConfig(viewConfig)}\n${logConfig(viewerConfig)}`); // update view config state and create config change log let configChangeLog = ''; Object.keys(viewerConfig).forEach(propertyName => { if (viewConfig[propertyName] === undefined || viewConfig[propertyName] !== viewerConfig[propertyName]) { viewConfig[propertyName] = viewerConfig[propertyName]; configChangeLog += `\n ${propertyName}: ${viewConfig[propertyName]}`; } }); if (configChangeLog.length > 0 ) { logMessage(`viewer.perspective-config-update \n${configChangeLog}`); // save updated view config state for vscode data view panel reload vscode.setState({ uri: dataUrl, table: dataTable, config: viewConfig, theme: theme, views: dataViews }); // notify data preview vscode.postMessage({ command: 'config', table: dataTable, config: viewConfig }); } } // end of updateConfig() /** * Deletes object property. * @param propertyName Property name to delete. */ function deleteProperty(propertyName, obj) { if (obj.hasOwnProperty(propertyName)) { delete obj[propertyName]; } } /** * Updates data view on data file change. * @param data Refresh data json with doc uri, fileName and raw data to display. */ function refresh(data) { try { // initialize data view vars and UI elements dataUrl = data.uri; dataFileName = data.fileName; dataTable = data.table; dataViews = data.views; theme = data.theme; logLevel = data.logLevel; title.innerText = data.fileName; // load table list for multi-table data views display loadTableList(data.tableNames, dataTable); // load new data view config, if different reloadDataViewConfig(data.config); // load file data logMessage(`refresh(dataTable='${dataTable}')\n\n loading data: ${data.fileName} ...`); const tableData = getData(data.fileName, data.data, data.schema); if (Array.isArray(viewData) && viewData.length === 0) { // initialize perspective viewer logMessage(`refresh(dataTable='${dataTable}')\n\n initializing data view: ${data.fileName} ...`); if (data.schema && Object.keys(data.schema).length > 0) { // viewer.columns = viewConfig['columns']; viewer.load(data.schema); viewer.update(tableData); } else { viewer.load(tableData); } if (toggleConfig) { // show viewer toggles on the 1st run viewer.toggleConfig(); toggleConfig = false; toggleDataViewConsoleDisplay(); } } else { // update viewer data without toggles reset logMessage(`refresh('${dataTable}')\n\n updating view data: ${data.fileName} ...`); viewer.clear(); viewer.update(tableData); } updateStats(); // save loaded view data for future data update checks viewData = tableData; } catch (error) { console.error(`data.view:refresh(): error: ${error.message}`); } } // end of refresh(data) /** * Loads data table names for multi-table data views display. * @param dataTableNames Data source table names array. * @param dataTable Requested data view table name. **/ function loadTableList(dataTableNames, dataTable) { if (tableNames.length <= 0) { logMessage(`loadTableList(\n tableNames = [${dataTableNames}]\n requestedDataTable = '${dataTable}'\n)`); dataTableNames.forEach(tableName => { tableSelector.innerHTML += `"`; tableNames.push(tableName); }); if (tableNames.length > 0) { // show table selector dropdown tableSelector.style.display = 'inline-block'; // set requested data table tableSelector.value = dataTable; } } } /** * Reloads data view config, resets view data and state * on new data view load, view config load, or data refresh. * @param dataViewConfig Data view config to load. **/ function reloadDataViewConfig(dataViewConfig) { // check view config if (JSON.stringify(viewConfig) !== JSON.stringify(dataViewConfig)) { logMessage(`reloadDataViewConfig(\n${logConfig(dataViewConfig)})`); // save updated view config viewConfig = dataViewConfig; // update data viewer for new view config display restoreConfig(viewConfig); // reset view data for reaload viewData = []; // update data view state vscode.setState({ uri: dataUrl, table: dataTable, config: viewConfig, theme: theme, views: dataViews }); } } /** * Gets data for display in perspective viewer. * @param fileName Data file name. * @param data Raw text or json array data. * @param schema Optional data schema. **/ function getData(fileName, data, schema = {}) { let tableData = []; logMessage(`getData()\n\n loading file data: ${fileName} ...`); // read file data const fileExt = fileName.substr(fileName.lastIndexOf('.')); // TODO: rework this for large data files streaming switch (fileExt) { case '.md': case '.csv': case '.tsv': case '.txt': case '.tab': // pass through text data for data view to load tableData = data; // log records count, i.e. text lines - 1 (header) for text data in csv format const recordCount = data.split('\n').length - 1; logMessage(`getData()\n\n records count: ${recordCount.toLocaleString()}`); break; case '.xls': case '.xlsb': case '.xlsx': case '.xlsm': case '.ods': case '.dif': case '.xml': case '.html': case '.avro': case '.env': case '.properties': case '.config': case '.ini': case '.yaml': case '.yml': case '.json5': case '.hjson': case '.jsonl': case '.ndjson': case '.parquet': // pass through loaded data json tableData = data; logMessage(`getData()\n\n records count: ${tableData.length.toLocaleString()}`); break; case '.arrow': // return empty data array since binary data is loaded elsewhere tableData = []; logMessage(`getData()\n\n schema: ${JSON.stringify(schema, null, 2)}`); break; default: // json // pass through loaded data json tableData = data; logMessage(`getData()\n\n records count: ${tableData.length.toLocaleString()}`); break; } return tableData; } // end of getData() /*--------------------- Data View toolbar action handlers ----------------------*/ /** * Loads raw data source content on view data source button click. */ function viewDataSource() { vscode.postMessage({ command: 'loadView', viewName: 'vscode.open', uri: `${dataUrl}` }); } /** * Saves data view config, and filtered json or csv data. */ function saveData() { const dataFileType = saveFileTypeSelector.value; switch (dataFileType) { case '.config': const dataFileName = dataUrl.substr(dataUrl.lastIndexOf('/') + 1); const data = { dataFileName: dataFileName, dataTable: dataTable, config: parseConfig(viewConfig) }; if (dataUrl.startsWith('http://') || dataUrl.startsWith('https://')) { // append remote data uri data['dataUri'] = dataUrl; } postData(data, dataFileType); break; case '.arrow': viewer.view.to_arrow().then(arrayBuffer => { postArrowData(arrayBuffer, dataFileType); }); break; case '.csv': case '.md': viewer.view.to_csv().then(csv => postData(csv, dataFileType)); break; case '.json': case '.jsonl': case '.json5': case '.hjson': case '.ndjson': case '.html': case '.ods': case '.xml': case '.xlsb': case '.xlsx': case '.yml': case '.properties': viewer.view.to_json({date_format: 'en-US'}).then(json => postData(json, dataFileType)); break; } } // end of saveData() /** * Parses data view config by converting config string properties * to arrays and objects for the data viewer web component attributes. * @param viewConfig View config object to parse. */ function parseConfig(viewConfig) { // set new view config plugin attribute viewConfig['plugin'] = viewConfig['view']; // create clean view config instance const config = {}; Object.keys(viewConfig).forEach(key => { config[key] = viewConfig[key]; if (typeof viewConfig[key] === 'string') { const attribute = String(viewConfig[key]); if (attribute.startsWith('{') || attribute.startsWith('[')) { // parse config object or array config[key] = JSON.parse(attribute); } } }); return config; } /** * Posts binary Arrow data for saving. * @param arrayBuffer Arrow data ArrayBuffer to save. * @param dataFileType Data file type to save: .arrow, .md, etc. */ function postArrowData(arrayBuffer, dataFileType) { const dataArray = new Uint8Array(arrayBuffer); postData(Array.from(dataArray), dataFileType); } /** * Posts Data View data for saving. * @param fileData File data to save. * @param dataFileType Data file type to save: .config, .arrow, .csv, .json(s), .md, .yml, etc. */ function postData(fileData, dataFileType) { vscode.postMessage({ command:'saveData', data: fileData, fileType: dataFileType }); } /** * Reloads view data. * @param dataTable Optional data table name for data files with multiple data sets. */ function reloadData(dataTable = '') { vscode.postMessage({ command: 'refresh', table: dataTable }); } /** * Loads requested table data for data files with multiple data sets. */ function loadTableData() { // get new table name selection from table selector list let tableName = tableSelector.value; if (!tableName || tableName === undefined) { // reset to empty for default data load dataTable = ''; } else { // reset view data table and config for new data load dataTable = tableName; viewData = []; restoringConfig = true; viewConfig = {}; viewer.reset(); restoringConfig = false; viewer.clear(); if (dataViews.hasOwnProperty(dataTable)) { viewConfig = dataViews[dataTable]; restoreConfig(viewConfig); } vscode.postMessage({ command: 'config', table: dataTable, config: viewConfig }); } reloadData(dataTable); } /** * Launches new data preview. */ function loadDataPreview() { let documentUri = dataUrl; // for new data.preview launch // check if loaded data view is for a view config file if (dataUrl.endsWith('.config') && viewData.length > 0 && viewData[0].hasOwnProperty('key') && viewData[0].hasOwnProperty('value')) { // extract data file name from config to load that data view const dataViewFileName = viewData.find(config => config.key === 'dataFileName'); const view = viewData.find(config => config.key === 'config.view'); if (view !== undefined && dataViewFileName !== undefined) { // must be our data.view config: swap it out to load that data documentUri = dataUrl.replace(dataFileName, dataViewFileName.value); } } // launch new data preview vscode.postMessage({ command: 'loadView', viewName: 'data.preview', uri: documentUri }); } /** * Launches new data preview for entered url in data url input textbox. */ function loadDataPreviewForUrl(e) { if (!e) e = window.event; const keyCode = e.keyCode || e.which; if (keyCode == '13') { const url = dataUrlInput.value; // launch new data preview vscode.postMessage({ command: 'loadView', viewName: 'data.preview', uri: url }); } } /** * Opens file dialog to launch new data preview * for the selected data file from the supported data formats list. **/ function openFile() { vscode.postMessage({command: 'openFile'}); } /** * Loads data preview help in a browser window. */ function loadHelp() { vscode.postMessage({ command: 'loadView', viewName: 'vscode.open', uri: 'https://github.com/RandomFractals/vscode-data-preview#usage-tips' }); } /** * Loads ko-fi sponsor button page in a browser window. */ function buyCoffee() { vscode.postMessage({ command: 'buyCoffee', viewName: 'vscode.open', uri: 'https://ko-fi.com/datapixy' }); } /** * Opens load data view config file dialog. */ function loadConfig() { vscode.postMessage({command:'loadConfig'}); } /** * Rolls back last view config changes. */ function undoConfig() { vscode.postMessage({command:'undoConfig'}); } /** * Applies next view config changes * in the recorded view config changes stack. */ function redoConfig() { vscode.postMessage({command:'redoConfig'}); } /** * Logs new data.view message to console for more info or debug. * @param message Log message text. * @param logLevel Optional log level type. */ function logMessage(message, logLevel = 'debug') { const category = 'data.view:'; switch (logLevel) { case 'warn': console.warn(category + message); break; case 'info': console.info(category + message); break; case 'error': console.error(category + message); break; default: // debug console.log(category + message); break; } if (dataViewConsoleText) { // log data view message to text console for display dataViewConsoleText.value += `${message}\n\n> `; } } /** * Converts data view config to string for console log display. * @param dataViewConfig Data view config object or string. */ function logConfig(dataViewConfig) { return (typeof dataViewConfig === 'string') ? dataViewConfig: JSON.stringify(dataViewConfig, null, 2); } /** * Toggles data view console text display * with data loading or config/data refresh debug messages * for issues troubleshooting and dev. */ function toggleDataViewConsoleDisplay() { // toggle data view console display dataViewConsoleText.style.display = (dataViewConsoleText.style.display === 'none') ? 'block': 'none'; } /** * Checks web client web assembly support. */ function supportsWebAssembly() { try { if (typeof WebAssembly === 'object' && typeof WebAssembly.instantiate === 'function') { const module = new WebAssembly.Module(Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00)); if (module instanceof WebAssembly.Module) { return new WebAssembly.Instance(module) instanceof WebAssembly.Instance; } } } catch (e) {} return false; }