Skip to content

Commit

Permalink
React metadata and file layout components
Browse files Browse the repository at this point in the history
  • Loading branch information
platypii committed Sep 14, 2024
1 parent 0711e86 commit 28ec43b
Show file tree
Hide file tree
Showing 7 changed files with 285 additions and 9 deletions.
21 changes: 18 additions & 3 deletions demo/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ import { FileMetaData, parquetMetadata, parquetMetadataAsync, parquetSchema } fr
import { parquetRead } from '../src/read.js'
import type { AsyncBuffer } from '../src/types.js'
import { asyncBufferFromUrl } from '../src/utils.js'
import Dropdown from './Dropdown.js'
import Dropzone from './Dropzone.js'
import Layout from './Layout.js'
import ParquetLayout from './ParquetLayout.js'
import ParquetMetadata from './ParquetMetadata.js'

type Lens = 'table' | 'metadata' | 'layout'

/**
* Hyparquet demo viewer page
Expand All @@ -17,11 +22,14 @@ export default function App() {
const [error, setError] = useState<Error>()
const [df, setDf] = useState<DataFrame>()
const [name, setName] = useState<string>()
const [lens, setLens] = useState<Lens>('table')
const [metadata, setMetadata] = useState<FileMetaData>()
const [byteLength, setByteLength] = useState<number>()

async function onFileDrop(file: File) {
const arrayBuffer = await file.arrayBuffer()
const metadata = parquetMetadata(arrayBuffer)
setMetadata(metadata)
setName(file.name)
setByteLength(file.size)
setDf(parquetDataFrame(arrayBuffer, metadata))
Expand All @@ -30,6 +38,7 @@ export default function App() {
async function onUrlDrop(url: string) {
const asyncBuffer = await asyncBufferFromUrl(url)
const metadata = await parquetMetadataAsync(asyncBuffer)
setMetadata(metadata)
setName(url)
setByteLength(asyncBuffer.byteLength)
setDf(parquetDataFrame(asyncBuffer, metadata))
Expand All @@ -41,13 +50,20 @@ export default function App() {
onError={(e) => setError(e)}
onFileDrop={onFileDrop}
onUrlDrop={onUrlDrop}>
{df && <>
{metadata && 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>
<Dropdown label={lens}>
<button onClick={() => setLens('table')}>Table</button>
<button onClick={() => setLens('metadata')}>Metadata</button>
<button onClick={() => setLens('layout')}>Layout</button>
</Dropdown>
</div>
<HighTable data={df} />
{lens === 'table' && <HighTable data={df} />}
{lens === 'metadata' && <ParquetMetadata metadata={metadata} />}
{lens === 'layout' && <ParquetLayout byteLength={byteLength!} metadata={metadata} />}
</>}
</Dropzone>
</Layout>
Expand Down Expand Up @@ -80,7 +96,6 @@ function parquetDataFrame(file: AsyncBuffer, metadata: FileMetaData): DataFrame
}
}


/**
* Returns the file size in human readable format.
*
Expand Down
73 changes: 73 additions & 0 deletions demo/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React from 'react'
import { ReactNode, useEffect, useRef, useState } from 'react'
import { cn } from './Layout.js'

interface DropdownProps {
label?: string
className?: string
children: ReactNode
}

/**
* Dropdown menu component.
*
* @param {Object} props
* @param {string} props.label - button label
* @param {string} props.className - custom class name for the dropdown container
* @param {ReactNode} props.children - dropdown menu items
* @returns {ReactNode}
* @example
* <Dropdown label='Menu'>
* <button>Item 1</button>
* <button>Item 2</button>
* </Dropdown>
*/
export default function Dropdown({ label, className, children }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)

function toggleDropdown() {
setIsOpen(!isOpen)
}

useEffect(() => {
function handleClickInside(event: MouseEvent) {
const target = event.target as Element
if (menuRef.current && menuRef.current.contains(target) && target?.tagName !== 'INPUT') {
setIsOpen(false)
}
}
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
setIsOpen(false)
}
}
document.addEventListener('click', handleClickInside)
document.addEventListener('keydown', handleEscape)
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('click', handleClickInside)
document.removeEventListener('keydown', handleEscape)
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])

return (
<div
className={cn('dropdown', className, isOpen && 'open')}
ref={dropdownRef}>
<button className='dropdown-button' onClick={toggleDropdown}>
{label}
</button>
<div className='dropdown-content' ref={menuRef}>
{children}
</div>
</div>
)
}
113 changes: 113 additions & 0 deletions demo/ParquetLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React, { ReactNode } from 'react'
import { getColumnRange } from '../src/column.js'
import type { FileMetaData } from '../src/metadata.js'
import { ColumnChunk } from '../src/types.js'

interface LayoutProps {
byteLength: number
metadata: FileMetaData
}

export default function ParquetLayout({ byteLength, metadata }: LayoutProps) {
const metadataStart = byteLength - metadata.metadata_length - 4
const metadataEnd = byteLength - 4

return <div className='viewer'>
<div className='layout'>
<Cell name='PAR1' start={0n} end={4n} />
<RowGroups metadata={metadata} />
<ColumnIndexes metadata={metadata} />
<Cell name='Metadata' start={metadataStart} end={metadataEnd} />
<Cell name='PAR1' start={metadataEnd} end={byteLength} />
</div>
</div>
}


function Cell<N extends bigint | number>({ name, start, end }: { name: string, start: N, end: N }) {
const bytes = end - start
return <div className="cell">
<label>{name}</label>
<ul>
<li>start {start.toLocaleString()}</li>
<li>bytes {bytes.toLocaleString()}</li>
<li>end {end.toLocaleString()}</li>
</ul>
</div>
}

function Group({ children, name, bytes }: { children: ReactNode, name?: string, bytes?: bigint }) {
return <div className="group">
<div className="group-header">
<label>{name}</label>
<span>{bytes === undefined ? '' : `bytes ${bytes.toLocaleString()}`}</span>
</div>
{children}
</div>
}

function RowGroups({ metadata }: { metadata: FileMetaData }) {
return <>
{metadata.row_groups.map((rowGroup, i) => (
<Group key={i} name={`RowGroup ${i}`} bytes={rowGroup.total_byte_size}>
{rowGroup.columns.map((column, j) => (
<Column key={j} column={column} />
))}
</Group>
))}
</>
}

function Column({ key, column }: { key: number, column: ColumnChunk }) {
if (!column.meta_data) return null
const end = getColumnRange(column.meta_data)[1]
const pages = [
{ name: 'Dictionary', offset: column.meta_data.dictionary_page_offset },
{ name: 'Data', offset: column.meta_data.data_page_offset },
{ name: 'Index', offset: column.meta_data.index_page_offset },
{ name: 'End', offset: end },
]
.filter(({ offset }) => offset !== undefined)
.sort((a, b) => Number(a.offset) - Number(b.offset))

const children = pages.slice(0, -1).map(({ name, offset }, index) => (
<Cell key={name} name={name} start={offset!} end={pages[index + 1].offset!} />
))


return <Group
key={key}
name={`Column ${column.meta_data?.path_in_schema.join('.')}`}
bytes={column.meta_data?.total_compressed_size}>
{children}
</Group>
}

function ColumnIndexes({ metadata }: { metadata: FileMetaData }) {
const indexPages = []
for (const rowGroup of metadata.row_groups) {
for (const column of rowGroup.columns) {
const columnName = column.meta_data?.path_in_schema.join('.')
if (column.column_index_offset) {
indexPages.push({
name: `ColumnIndex ${columnName}`,
start: column.column_index_offset,
end: column.column_index_offset + BigInt(column.column_index_length || 0),
})
}
if (column.offset_index_offset) {
indexPages.push({
name: `OffsetIndex ${columnName}`,
start: column.offset_index_offset,
end: column.offset_index_offset + BigInt(column.offset_index_length || 0),
})
}
}
}

return <Group name='ColumnIndexes'>
{indexPages.map(({ name, start, end }, index) => (
<Cell key={index} name={name} start={start} end={end} />
))}
</Group>
}
13 changes: 13 additions & 0 deletions demo/ParquetMetadata.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react'
import type { FileMetaData } from '../src/metadata.js'
import { toJson } from '../src/utils.js'

interface MetadataProps {
metadata: FileMetaData
}

export default function ParquetMetadata({ metadata }: MetadataProps) {
return <code className='viewer'>
{JSON.stringify(toJson(metadata), null, ' ')}
</code>
}
4 changes: 2 additions & 2 deletions demo/bundle.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion demo/bundle.min.js.map

Large diffs are not rendered by default.

68 changes: 65 additions & 3 deletions demo/demo.css
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,72 @@ main,
overflow-y: auto;
}

#table {
/* dropdown */
.dropdown {
display: inline-block;
position: relative;
text-overflow: ellipsis;
user-select: none;
white-space: nowrap;
}

.dropdown-button,
.dropdown-button:active,
.dropdown-button:focus,
.dropdown-button:hover {
background: transparent;
border: none;
color: inherit;
cursor: pointer;
font-size: inherit;
height: 24px;
max-width: 300px;
overflow: hidden;
}
/* dropdown caret */
.dropdown-button::before {
content: "\25bc";
display: inline-block;
font-size: 10px;
margin-right: 4px;
transform: rotate(-90deg);
transition: transform 0.1s;
}
.open .dropdown-button::before {
transform: rotate(0deg);
}
/* dropdown menu options */
.dropdown-content {
position: absolute;
left: 0;
background-color: #ccc;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
box-shadow: 0px 8px 8px 0px rgba(0, 0, 0, 0.2);
max-height: 0;
max-width: 200px;
min-width: 120px;
transition: max-height 0.1s ease-out;
overflow-y: hidden;
z-index: 20;
}
.dropdown-content > button {
background: none;
border: none;
padding: 8px 16px;
text-align: left;
}
/* dropdown menu options hover */
.dropdown-content > button:active,
.dropdown-content > button:focus,
.dropdown-content > button:hover {
background-color: rgba(95, 75, 133, 0.4);
}
/* roll out dropdown menu */
.open .dropdown-content {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
max-height: 170px;
}

/* welcome */
Expand Down

0 comments on commit 28ec43b

Please sign in to comment.