mirror of
https://github.com/asadbek064/hyparquet.git
synced 2025-12-27 15:46:36 +00:00
Hyperparam style for demo
This commit is contained in:
parent
c6111423c1
commit
0711e86d33
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*.min.js -diff
|
||||
*.min.js.map -diff
|
||||
97
demo/App.tsx
Normal file
97
demo/App.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import HighTable, { DataFrame } from 'hightable'
|
||||
import { compressors } from 'hyparquet-compressors'
|
||||
import React, { useState } from 'react'
|
||||
import { FileMetaData, parquetMetadata, parquetMetadataAsync, parquetSchema } from '../src/metadata.js'
|
||||
import { parquetRead } from '../src/read.js'
|
||||
import type { AsyncBuffer } from '../src/types.js'
|
||||
import { asyncBufferFromUrl } from '../src/utils.js'
|
||||
import Dropzone from './Dropzone.js'
|
||||
import Layout from './Layout.js'
|
||||
|
||||
/**
|
||||
* Hyparquet demo viewer page
|
||||
* @returns {ReactNode}
|
||||
*/
|
||||
export default function App() {
|
||||
const [progress, setProgress] = useState<number>()
|
||||
const [error, setError] = useState<Error>()
|
||||
const [df, setDf] = useState<DataFrame>()
|
||||
const [name, setName] = useState<string>()
|
||||
const [byteLength, setByteLength] = useState<number>()
|
||||
|
||||
async function onFileDrop(file: File) {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const metadata = parquetMetadata(arrayBuffer)
|
||||
setName(file.name)
|
||||
setByteLength(file.size)
|
||||
setDf(parquetDataFrame(arrayBuffer, metadata))
|
||||
document.getElementById('welcome')?.remove()
|
||||
}
|
||||
async function onUrlDrop(url: string) {
|
||||
const asyncBuffer = await asyncBufferFromUrl(url)
|
||||
const metadata = await parquetMetadataAsync(asyncBuffer)
|
||||
setName(url)
|
||||
setByteLength(asyncBuffer.byteLength)
|
||||
setDf(parquetDataFrame(asyncBuffer, metadata))
|
||||
document.getElementById('welcome')?.remove()
|
||||
}
|
||||
|
||||
return <Layout progress={progress} error={error}>
|
||||
<Dropzone
|
||||
onError={(e) => setError(e)}
|
||||
onFileDrop={onFileDrop}
|
||||
onUrlDrop={onUrlDrop}>
|
||||
{df && <>
|
||||
<div className='top-header'>{name}</div>
|
||||
<div className='view-header'>
|
||||
{byteLength !== undefined && <span title={byteLength.toLocaleString() + ' bytes'}>{formatFileSize(byteLength)}</span>}
|
||||
<span>{df.numRows.toLocaleString()} rows</span>
|
||||
</div>
|
||||
<HighTable data={df} />
|
||||
</>}
|
||||
</Dropzone>
|
||||
</Layout>
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a parquet file into a dataframe.
|
||||
*
|
||||
* @param {AsyncBuffer} file - parquet file asyncbuffer
|
||||
* @param {FileMetaData} metadata - parquet file metadata
|
||||
* @returns {DataFrame} dataframe
|
||||
*/
|
||||
function parquetDataFrame(file: AsyncBuffer, metadata: FileMetaData): DataFrame {
|
||||
const { children } = parquetSchema(metadata)
|
||||
return {
|
||||
header: children.map(child => child.element.name),
|
||||
numRows: Number(metadata.num_rows),
|
||||
/**
|
||||
* @param {number} rowStart
|
||||
* @param {number} rowEnd
|
||||
* @returns {Promise<any[][]>}
|
||||
*/
|
||||
rows(rowStart, rowEnd) {
|
||||
console.log(`reading rows ${rowStart}-${rowEnd}`)
|
||||
return new Promise((resolve, reject) => {
|
||||
parquetRead({ file, compressors, rowStart, rowEnd, onComplete: resolve })
|
||||
.catch(reject)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the file size in human readable format.
|
||||
*
|
||||
* @param {number} bytes file size in bytes
|
||||
* @returns {string} formatted file size string
|
||||
*/
|
||||
function formatFileSize(bytes: number): string {
|
||||
const sizes = ['b', 'kb', 'mb', 'gb', 'tb']
|
||||
if (bytes === 0) return '0 b'
|
||||
const i = Math.floor(Math.log2(bytes) / 10)
|
||||
if (i === 0) return bytes + ' b'
|
||||
const base = bytes / Math.pow(1024, i)
|
||||
return (base < 10 ? base.toFixed(1) : Math.round(base)) + ' ' + sizes[i]
|
||||
}
|
||||
131
demo/Dropzone.tsx
Normal file
131
demo/Dropzone.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import React from 'react'
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface DropzoneProps {
|
||||
children: ReactNode
|
||||
onFileDrop: (file: File) => void
|
||||
onUrlDrop: (url: string) => void
|
||||
onError: (error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A dropzone component for uploading files.
|
||||
*
|
||||
* Shows a fullscreen overlay when files are dragged over the dropzone.
|
||||
*
|
||||
* You can have an element inside the dropzone that triggers the file input
|
||||
* dialog when clicked by adding the class 'dropzone-select' to it.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {ReactNode} props.children - message to display in dropzone.
|
||||
* @param {Function} props.onFileDrop - called when a file is dropped.
|
||||
* @param {Function} props.onUrlDrop - called when a url is dropped.
|
||||
* @param {Function} props.onError - called when an error occurs.
|
||||
* @returns {ReactNode}
|
||||
*/
|
||||
export default function Dropzone({ children, onFileDrop, onUrlDrop, onError }: DropzoneProps) {
|
||||
const dropzoneRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
// number of dragenter events minus dragleave events
|
||||
const [enters, setEnters] = useState(0)
|
||||
|
||||
/**
|
||||
* Trigger file input dialog.
|
||||
* @param {MouseEvent} e - click
|
||||
*/
|
||||
function triggerFileSelect(e: React.MouseEvent<HTMLDivElement>) {
|
||||
// If click inside '.dropzone-select', activate file input dialog
|
||||
if ((e.target as Element).closest('.dropzone-select')) {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file selection event.
|
||||
* Recursively upload files and directories, in parallel.
|
||||
* @param {ChangeEvent} e
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const { files } = e.target
|
||||
if (!files) return
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
// TODO: Load file view
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const dropzone = dropzoneRef.current
|
||||
if (!dropzone) return
|
||||
|
||||
// Attach drag-and-drop event listeners
|
||||
function onDragEnter(e: DragEvent) {
|
||||
// check if any of the items are files (not strings)
|
||||
const items = e.dataTransfer?.items
|
||||
if (!items) return
|
||||
if (!Array.from(items).some(item => item.kind === 'file')) return
|
||||
setEnters(enters => enters + 1)
|
||||
}
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
}
|
||||
function onDragLeave() {
|
||||
setEnters(enters => enters - 1)
|
||||
}
|
||||
function handleFileDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
setEnters(0)
|
||||
|
||||
if (!e.dataTransfer) throw new Error('Missing dataTransfer')
|
||||
const { files, items } = e.dataTransfer
|
||||
if (files.length > 0) {
|
||||
const file = files[0]
|
||||
onFileDrop(file)
|
||||
}
|
||||
if (items.length > 0) {
|
||||
const item = items[0]
|
||||
if (item.kind === 'string') {
|
||||
item.getAsString(url => {
|
||||
if (url.startsWith('http')) {
|
||||
onUrlDrop(url)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dropzone.addEventListener('dragenter', onDragEnter)
|
||||
dropzone.addEventListener('dragover', onDragOver)
|
||||
dropzone.addEventListener('dragleave', onDragLeave)
|
||||
dropzone.addEventListener('drop', handleFileDrop)
|
||||
|
||||
// Cleanup event listeners when component is unmounted
|
||||
return () => {
|
||||
dropzone.removeEventListener('dragenter', onDragEnter)
|
||||
dropzone.removeEventListener('dragover', onDragOver)
|
||||
dropzone.removeEventListener('dragleave', onDragLeave)
|
||||
dropzone.removeEventListener('drop', handleFileDrop)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={enters > 0 ? 'dropzone hover' : 'dropzone'}
|
||||
onClick={triggerFileSelect}
|
||||
ref={dropzoneRef}>
|
||||
{children}
|
||||
<div className='overlay'>
|
||||
<div className='target'>
|
||||
<div>Drop files to view. 👀</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type="file" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
demo/Layout.tsx
Normal file
57
demo/Layout.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
progress?: number
|
||||
error?: Error
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout for shared UI.
|
||||
* Content div style can be overridden by className prop.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {ReactNode} props.children - content to display inside the layout
|
||||
* @param {string | undefined} props.className - additional class names to apply to the content container
|
||||
* @param {number | undefined} props.progress - progress bar value
|
||||
* @param {Error} props.error - error message to display
|
||||
* @returns {ReactNode}
|
||||
*/
|
||||
export default function Layout({ children, className, progress, error }: LayoutProps) {
|
||||
const errorMessage = error?.toString()
|
||||
if (error) console.error(error)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'hyparquet demo - apache parquet file viewer online'
|
||||
}, [])
|
||||
|
||||
return <>
|
||||
<div className='content-container'>
|
||||
<div className={cn('content', className)}>
|
||||
{children}
|
||||
</div>
|
||||
<div className={cn('error-bar', error && 'show-error')}>{errorMessage}</div>
|
||||
</div>
|
||||
{progress !== undefined && progress < 1 &&
|
||||
<div className={'progress-bar'} role='progressbar'>
|
||||
<div style={{ width: `${100 * progress}%` }} />
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to join class names.
|
||||
* Filters out falsy values and joins the rest.
|
||||
*
|
||||
* @param {...string | undefined | false} names - class name(s) to join
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cn(...names: (string | undefined | false)[]): string {
|
||||
return names.filter(n => n).join(' ')
|
||||
}
|
||||
|
||||
export function Spinner({ className }: { className: string }) {
|
||||
return <div className={cn('spinner', className)}></div>
|
||||
}
|
||||
4
demo/bundle.min.js
vendored
4
demo/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
201
demo/demo.css
201
demo/demo.css
@ -10,32 +10,40 @@ body {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
nav {
|
||||
border-right: 1px solid #ddd;
|
||||
min-width: 320px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
min-width: 0;
|
||||
max-width: 40%;
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20pt;
|
||||
font-size: 22pt;
|
||||
}
|
||||
h2 {
|
||||
margin-top: 10px;
|
||||
font-size: 12pt;
|
||||
}
|
||||
p {
|
||||
margin: 10px 0;
|
||||
margin: 15px 0;
|
||||
}
|
||||
code {
|
||||
font-family: monospace;
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #c11;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
#overlay {
|
||||
/* dropzone */
|
||||
.dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.dropzone.hover .overlay {
|
||||
display: flex;
|
||||
}
|
||||
.overlay {
|
||||
align-items: center;
|
||||
font-size: 125%;
|
||||
position: absolute;
|
||||
@ -50,37 +58,157 @@ p {
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
#dropzone {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
max-width: 100vw;
|
||||
/* sidebar */
|
||||
nav {
|
||||
height: 100vh;
|
||||
min-width: 48px;
|
||||
background-image: linear-gradient(to bottom, #667, #585669);
|
||||
box-shadow: 0 0 4px rgba(10, 10, 10, 0.5);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* brand logo */
|
||||
.brand {
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
filter: drop-shadow(0 0 2px #444);
|
||||
font-family: 'Century Gothic', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
text-orientation: mixed;
|
||||
opacity: 0.85;
|
||||
padding: 10px 12px;
|
||||
user-select: none;
|
||||
writing-mode: vertical-rl;
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand:hover {
|
||||
color: #fff;
|
||||
filter: drop-shadow(0 0 2px #333);
|
||||
opacity: 0.9;
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand::before {
|
||||
content: '';
|
||||
background: url(logo.svg) no-repeat 0 center;
|
||||
background-size: 26px;
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* content area */
|
||||
main,
|
||||
#content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#app {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* content area */
|
||||
.content-container {
|
||||
min-width: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: 100vh;
|
||||
padding: 0;
|
||||
/* no outer scrollbars */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-header {
|
||||
align-items: center;
|
||||
background: linear-gradient(to right, #353540, #24202b);
|
||||
color: #dde4ea;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
justify-content: space-between;
|
||||
min-height: 32px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.top-header {
|
||||
color: #f0f8ff;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
align-items: center;
|
||||
background-color: #ccc;
|
||||
color: #444;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
/* all one line */
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.viewer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
white-space: pre-wrap;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#table {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* welcome */
|
||||
#welcome {
|
||||
border: 2px dashed #08e;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
border: 2px #777;
|
||||
border-radius: 10px;
|
||||
color: #444;
|
||||
margin: 10px;
|
||||
padding: 10px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
font-size: 20px;
|
||||
justify-content: center;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
#welcome ul {
|
||||
margin-top: 10px;
|
||||
margin-left: 30px;
|
||||
}
|
||||
|
||||
/* file upload */
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
#overlay {
|
||||
.overlay {
|
||||
font-size: 125%;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
@ -94,7 +222,7 @@ input[type="file"] {
|
||||
padding: 12px;
|
||||
z-index: 40;
|
||||
}
|
||||
.over #overlay {
|
||||
.over .overlay {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@ -222,6 +350,10 @@ td:first-child {
|
||||
}
|
||||
|
||||
/* layout */
|
||||
.layout {
|
||||
margin: 10px;
|
||||
max-width: 480px;
|
||||
}
|
||||
.layout,
|
||||
.layout .group,
|
||||
.layout .cell {
|
||||
@ -249,7 +381,6 @@ td:first-child {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
nav ul,
|
||||
.layout div ul {
|
||||
list-style: none;
|
||||
}
|
||||
@ -258,27 +389,3 @@ nav ul,
|
||||
padding: 2px 4px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.collapsed > :not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.layout h2 {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.layout h2::before {
|
||||
content: "▼";
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
margin: 0 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.layout.collapsed h2::before {
|
||||
content: "▶";
|
||||
}
|
||||
|
||||
.layout pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
117
demo/demo.js
117
demo/demo.js
@ -1,113 +1,10 @@
|
||||
import HighTable from 'hightable'
|
||||
import { compressors } from 'hyparquet-compressors'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import {
|
||||
parquetMetadata, parquetMetadataAsync, parquetRead, parquetSchema, toJson,
|
||||
} from '../src/hyparquet.js'
|
||||
import { asyncBufferFromUrl } from '../src/utils.js'
|
||||
import { initDropzone } from './dropzone.js'
|
||||
import { fileLayout, fileMetadata } from './layout.js'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.js'
|
||||
|
||||
/**
|
||||
* @typedef {import('../src/types.js').AsyncBuffer} AsyncBuffer
|
||||
* @typedef {import('../src/types.js').FileMetaData} FileMetaData
|
||||
*/
|
||||
const app = document.getElementById('app')
|
||||
if (!app) throw new Error('missing app element')
|
||||
|
||||
const content = document.querySelectorAll('#content')[0]
|
||||
|
||||
// Initialize drag-and-drop
|
||||
initDropzone(handleFileDrop, handleUrlDrop)
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
*/
|
||||
async function handleUrlDrop(url) {
|
||||
content.innerHTML = ''
|
||||
try {
|
||||
const asyncBuffer = await asyncBufferFromUrl(url)
|
||||
const metadata = await parquetMetadataAsync(asyncBuffer)
|
||||
await render(asyncBuffer, metadata, `<a href="${url}">${url}</a>`)
|
||||
} catch (e) {
|
||||
console.error('Error fetching url', e)
|
||||
content.innerHTML += `<div class="error">Error fetching url ${url}\n${e}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {File} file
|
||||
*/
|
||||
function handleFileDrop(file) {
|
||||
content.innerHTML = ''
|
||||
const reader = new FileReader()
|
||||
reader.onload = async e => {
|
||||
try {
|
||||
const arrayBuffer = e.target?.result
|
||||
if (!(arrayBuffer instanceof ArrayBuffer)) throw new Error('Missing arrayBuffer')
|
||||
const metadata = parquetMetadata(arrayBuffer)
|
||||
await render(arrayBuffer, metadata, file.name)
|
||||
} catch (e) {
|
||||
console.error('Error parsing file', e)
|
||||
content.innerHTML = `<strong>${file.name}</strong>`
|
||||
content.innerHTML += `<div class="error">Error parsing file\n${e}</div>`
|
||||
}
|
||||
}
|
||||
reader.onerror = e => {
|
||||
console.error('Error reading file', e)
|
||||
content.innerHTML = `<strong>${file.name}</strong>`
|
||||
content.innerHTML += `<div class="error">Error reading file\n${e.target?.error}</div>`
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AsyncBuffer} file
|
||||
* @param {FileMetaData} metadata
|
||||
* @param {string} name
|
||||
*/
|
||||
function render(file, metadata, name) {
|
||||
renderSidebar(file, metadata, name)
|
||||
|
||||
const { children } = parquetSchema(metadata)
|
||||
|
||||
const dataframe = {
|
||||
header: children.map(child => child.element.name),
|
||||
numRows: Number(metadata.num_rows),
|
||||
/**
|
||||
* @param {number} rowStart
|
||||
* @param {number} rowEnd
|
||||
* @returns {Promise<any[][]>}
|
||||
*/
|
||||
rows(rowStart, rowEnd) {
|
||||
console.log(`reading rows ${rowStart}-${rowEnd}`)
|
||||
return new Promise((resolve, reject) => {
|
||||
parquetRead({ file, compressors, rowStart, rowEnd, onComplete: resolve })
|
||||
.catch(reject)
|
||||
})
|
||||
},
|
||||
}
|
||||
renderTable(dataframe)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AsyncBuffer} asyncBuffer
|
||||
* @param {FileMetaData} metadata
|
||||
* @param {string} name
|
||||
*/
|
||||
function renderSidebar(asyncBuffer, metadata, name) {
|
||||
const sidebar = /** @type {HTMLElement} */ (document.getElementById('sidebar'))
|
||||
sidebar.innerHTML = `<div id="filename">${name}</div>`
|
||||
sidebar.appendChild(fileMetadata(toJson(metadata)))
|
||||
sidebar.appendChild(fileLayout(metadata, asyncBuffer))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('hightable').DataFrame} data
|
||||
*/
|
||||
function renderTable(data) {
|
||||
// Load HighTable.tsx and render
|
||||
const container = document.getElementById('content')
|
||||
// @ts-expect-error ReactDOM type issue
|
||||
const root = ReactDOM.createRoot(container)
|
||||
root.render(React.createElement(HighTable, { data }))
|
||||
}
|
||||
// @ts-expect-error TODO: fix react createRoot type
|
||||
const root = ReactDOM.createRoot(document.getElementById('app'))
|
||||
root.render(React.createElement(App))
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
/**
|
||||
* Initialize the dropzone for file and url drag-and-drop.
|
||||
*
|
||||
* @param {Function} handleFileDrop
|
||||
* @param {Function} handleUrlDrop
|
||||
*/
|
||||
export function initDropzone(handleFileDrop, handleUrlDrop) {
|
||||
let enterCount = 0
|
||||
|
||||
const dropzone = /** @type {HTMLElement} */ (document.getElementById('dropzone'))
|
||||
const fileInput = /** @type {HTMLInputElement} */ (document.getElementById('file-input'))
|
||||
const welcome = document.querySelectorAll('#welcome')[0]
|
||||
|
||||
// Click to select file
|
||||
welcome.addEventListener('click', () => {
|
||||
fileInput?.click()
|
||||
})
|
||||
fileInput?.addEventListener('change', () => {
|
||||
if (fileInput.files?.length) {
|
||||
handleFileDrop(fileInput.files[0])
|
||||
}
|
||||
})
|
||||
|
||||
dropzone.addEventListener('dragenter', e => {
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
dropzone.classList.add('over')
|
||||
enterCount++
|
||||
})
|
||||
|
||||
dropzone.addEventListener('dragover', e => {
|
||||
e.preventDefault()
|
||||
})
|
||||
|
||||
dropzone.addEventListener('dragleave', () => {
|
||||
enterCount--
|
||||
if (!enterCount) dropzone.classList.remove('over')
|
||||
})
|
||||
|
||||
dropzone.addEventListener('drop', e => {
|
||||
e.preventDefault() // prevent dropped file from being "downloaded"
|
||||
dropzone.classList.remove('over')
|
||||
|
||||
if (!e.dataTransfer) throw new Error('Missing dataTransfer')
|
||||
const { files, items } = e.dataTransfer
|
||||
if (files.length > 0) {
|
||||
const file = files[0]
|
||||
handleFileDrop(file)
|
||||
}
|
||||
if (items.length > 0) {
|
||||
const item = items[0]
|
||||
if (item.kind === 'string') {
|
||||
item.getAsString(str => {
|
||||
if (str.startsWith('http')) {
|
||||
handleUrlDrop(str)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
122
demo/layout.js
122
demo/layout.js
@ -1,122 +0,0 @@
|
||||
/**
|
||||
* @typedef {import('../src/types.js').FileMetaData} FileMetaData
|
||||
*/
|
||||
|
||||
import { getColumnRange } from '../src/column.js'
|
||||
|
||||
/**
|
||||
* @param {FileMetaData} metadata
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
export function fileMetadata(metadata) {
|
||||
let html = '<h2>Metadata</h2>'
|
||||
html += `<pre>${JSON.stringify(metadata, null, 2)}</pre>`
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = html
|
||||
div.classList.add('layout', 'collapsed') // start collapsed
|
||||
div.children[0].addEventListener('click', () => {
|
||||
div.classList.toggle('collapsed')
|
||||
})
|
||||
return div
|
||||
}
|
||||
|
||||
/**
|
||||
* Render parquet file layout.
|
||||
*
|
||||
* @param {FileMetaData} metadata
|
||||
* @param {import('../src/types.js').AsyncBuffer} asyncBuffer
|
||||
* @returns {HTMLDivElement}
|
||||
*/
|
||||
export function fileLayout(metadata, asyncBuffer) {
|
||||
let html = '<h2>File layout</h2>'
|
||||
html += cell('PAR1', 0n, 4n) // magic number
|
||||
|
||||
// data pages by row group and column
|
||||
/** @type {[string, bigint, bigint][]} */
|
||||
const indexPages = []
|
||||
for (const rowGroupIndex in metadata.row_groups) {
|
||||
const rowGroup = metadata.row_groups[rowGroupIndex]
|
||||
html += group(`RowGroup ${rowGroupIndex}`, rowGroup.total_byte_size)
|
||||
for (const column of rowGroup.columns) {
|
||||
const columnName = column.meta_data?.path_in_schema.join('.')
|
||||
html += group(`Column ${columnName}`, column.meta_data?.total_compressed_size)
|
||||
if (column.meta_data) {
|
||||
const end = getColumnRange(column.meta_data)[1]
|
||||
/* eslint-disable no-extra-parens */
|
||||
const pages = (/** @type {[string, bigint][]} */
|
||||
([
|
||||
['Dictionary', column.meta_data.dictionary_page_offset],
|
||||
['Data', column.meta_data.data_page_offset],
|
||||
['Index', column.meta_data.index_page_offset],
|
||||
['End', end],
|
||||
]))
|
||||
.filter(([, offset]) => offset !== undefined)
|
||||
.sort((a, b) => Number(a[1]) - Number(b[1]))
|
||||
|
||||
for (let i = 0; i < pages.length - 1; i++) {
|
||||
const [name, start] = pages[i]
|
||||
const end = pages[i + 1][1]
|
||||
html += cell(name, start, end)
|
||||
}
|
||||
}
|
||||
if (column.column_index_offset) {
|
||||
indexPages.push([`ColumnIndex RowGroup${rowGroupIndex} ${columnName}`, column.column_index_offset, BigInt(column.column_index_length || 0)])
|
||||
}
|
||||
if (column.offset_index_offset) {
|
||||
indexPages.push([`OffsetIndex RowGroup${rowGroupIndex} ${columnName}`, column.offset_index_offset, BigInt(column.offset_index_length || 0)])
|
||||
}
|
||||
html += '</div>'
|
||||
}
|
||||
html += '</div>'
|
||||
}
|
||||
|
||||
// column and offset indexes
|
||||
for (const [name, start, length] of indexPages.sort((a, b) => Number(a[1]) - Number(b[1]))) {
|
||||
html += cell(name, start, start + length)
|
||||
}
|
||||
|
||||
// metadata footer
|
||||
const metadataStart = BigInt(asyncBuffer.byteLength - metadata.metadata_length - 4)
|
||||
const metadataEnd = BigInt(asyncBuffer.byteLength - 4)
|
||||
html += cell('Metadata', metadataStart, metadataEnd)
|
||||
html += cell('PAR1', metadataEnd, BigInt(asyncBuffer.byteLength)) // magic number
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = html
|
||||
div.classList.add('layout', 'collapsed') // start collapsed
|
||||
div.children[0].addEventListener('click', () => {
|
||||
div.classList.toggle('collapsed')
|
||||
})
|
||||
return div
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {bigint} [bytes]
|
||||
* @returns {string}
|
||||
*/
|
||||
function group(name, bytes) {
|
||||
return `<div class="group">
|
||||
<div class="group-header">
|
||||
<label>${name}</label>
|
||||
<span>${bytes === undefined ? '' : `bytes ${bytes.toLocaleString()}`}</span>
|
||||
</div>`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {bigint} start
|
||||
* @param {bigint} end
|
||||
* @returns {string}
|
||||
*/
|
||||
function cell(name, start, end) {
|
||||
const bytes = end - start
|
||||
return `
|
||||
<div class="cell">
|
||||
<label>${name}</label>
|
||||
<ul>
|
||||
<li>start ${start.toLocaleString()}</li>
|
||||
<li>bytes ${bytes.toLocaleString()}</li>
|
||||
<li>end ${end.toLocaleString()}</li>
|
||||
</ul>
|
||||
</div>`
|
||||
}
|
||||
8
demo/logo.svg
Normal file
8
demo/logo.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg width="96" height="96" version="1.1" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m48 3.5 38.37 22.25v44.5l-38.37 22.25-38.37-22.25v-44.5z" fill="#43a" stroke="#43a" stroke-linejoin="round" stroke-width="7"/>
|
||||
<g fill="none" stroke="#fff" stroke-linejoin="round" stroke-width="8">
|
||||
<path d="m48 48-29.14-17 2.81e-4 34 29.14 17z"/>
|
||||
<path d="m77.14 31-29.14 17v34l29.14-17z"/>
|
||||
<path d="m48 48-29.14-17 29.14-17 29.14 17z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 466 B |
30
index.html
30
index.html
@ -12,32 +12,30 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="dropzone">
|
||||
<div id="overlay">
|
||||
Drop .parquet file
|
||||
</div>
|
||||
<nav>
|
||||
<nav>
|
||||
<a class="brand" href='https://hyparam.github.io/hyparquet/'>
|
||||
hyparquet
|
||||
</a>
|
||||
</nav>
|
||||
<main id="content">
|
||||
<div id="app"></div>
|
||||
<div id="welcome">
|
||||
<h1>hyparquet</h1>
|
||||
<h2>parquet file reader</h2>
|
||||
<sub>/haɪ pɑːrˈkeɪ/</sub>
|
||||
<h2>in-browser parquet file reader</h2>
|
||||
<p>
|
||||
Online demo of <a href="https://github.com/hyparam/hyparquet">hyparquet</a>: a parser for apache parquet files.
|
||||
Uses <a href="https://github.com/hyparam/hightable">hightable</a> for high performance windowed table viewing.
|
||||
</p>
|
||||
<p>
|
||||
Drag and drop a parquet file onto the dropzone to see parquet data.
|
||||
Drag and drop a parquet file (or url) to see your parquet data. 👀
|
||||
</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/hyparam/hyparquet">github</a></li>
|
||||
<li><a href="https://www.npmjs.com/package/hyparquet">npm</a></li>
|
||||
<li><a href="https://github.com/hyparam/hyparquet">hyparquet github</a></li>
|
||||
<li><a href="https://www.npmjs.com/package/hyparquet">hyparquet npm</a></li>
|
||||
</ul>
|
||||
<div id="sidebar"></div>
|
||||
</nav>
|
||||
<div id="content">
|
||||
<div id="welcome">
|
||||
Drop .parquet file here
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<input id="file-input" type="file">
|
||||
|
||||
<script type="module" src="demo/bundle.min.js"></script>
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
"@rollup/plugin-node-resolve": "15.2.3",
|
||||
"@rollup/plugin-replace": "5.0.7",
|
||||
"@rollup/plugin-terser": "0.4.4",
|
||||
"@rollup/plugin-typescript": "11.1.6",
|
||||
"@types/node": "22.5.4",
|
||||
"@types/react": "18.3.5",
|
||||
"@types/react-dom": "18.3.0",
|
||||
|
||||
@ -2,6 +2,7 @@ import commonjs from '@rollup/plugin-commonjs'
|
||||
import resolve from '@rollup/plugin-node-resolve'
|
||||
import replace from '@rollup/plugin-replace'
|
||||
import terser from '@rollup/plugin-terser'
|
||||
import typescript from '@rollup/plugin-typescript'
|
||||
|
||||
export default {
|
||||
input: 'demo/demo.js',
|
||||
@ -18,5 +19,6 @@ export default {
|
||||
}),
|
||||
resolve({ browser: true }),
|
||||
terser(),
|
||||
typescript(),
|
||||
],
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"jsx": "react",
|
||||
"lib": ["esnext", "dom"],
|
||||
"module": "nodenext",
|
||||
"noEmit": true,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user