Skip to content

Commit

Permalink
View header
Browse files Browse the repository at this point in the history
  • Loading branch information
platypii committed Sep 6, 2024
1 parent 4a96fcd commit 7f91d98
Show file tree
Hide file tree
Showing 17 changed files with 186 additions and 83 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![npm](https://img.shields.io/npm/v/hyperparam)](https://www.npmjs.com/package/hyperparam)
[![workflow status](https://github.com/hyparam/hyperparam-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/hyparam/hyperparam-cli/actions)
[![mit license](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
![coverage](https://img.shields.io/badge/Coverage-37-darkred)
![coverage](https://img.shields.io/badge/Coverage-38-darkred)

This is the hyperparam cli tool.

Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@
"@rollup/plugin-terser": "0.4.4",
"@rollup/plugin-typescript": "11.1.6",
"@testing-library/react": "16.0.1",
"@types/node": "22.5.1",
"@types/node": "22.5.4",
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "8.3.0",
"@typescript-eslint/eslint-plugin": "8.4.0",
"@vitejs/plugin-react": "4.3.1",
"@vitest/coverage-v8": "2.0.5",
"eslint": "8.57.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-import": "2.30.0",
"eslint-plugin-jsdoc": "50.2.2",
"jsdom": "25.0.0",
"react": "18.3.1",
Expand Down
4 changes: 2 additions & 2 deletions public/build/app.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/build/app.js.map

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,22 @@ main {
.viewer {
display: flex;
flex: 1;
flex-direction: column;
white-space: pre-wrap;
overflow-y: auto;
}
.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;
}
/* viewers */
.text {
background-color: #22222b;
Expand Down
37 changes: 30 additions & 7 deletions src/components/Cell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { stringify } from 'hightable'
import React, { useEffect, useState } from 'react'
import { parquetDataFrame } from '../tableProvider.js'
import { asyncBufferFrom, parquetDataFrame } from '../tableProvider.js'
import Highlight from './Highlight.js'
import Layout from './Layout.js'

Expand Down Expand Up @@ -36,12 +35,16 @@ export default function CellView() {
async function loadCellData() {
try {
// TODO: handle first row > 100kb
setProgress(0.33)
const df = await parquetDataFrame(url)
setProgress(0.66)
setProgress(0.25)
const asyncBuffer = await asyncBufferFrom(url)
setProgress(0.5)
const df = await parquetDataFrame(asyncBuffer)
setProgress(0.75)
const rows = await df.rows(row, row + 1)
const cell = rows[0][col]
setText(stringify(cell))
const text = stringify(cell)
console.log('cell', cell, text)
setText(text)
} catch (error) {
setError(error as Error)
} finally {
Expand Down Expand Up @@ -73,6 +76,26 @@ export default function CellView() {
</div>
</nav>

<Highlight text={text || ''} />
{/* <Highlight text={text || ''} /> */}
<pre className="viewer text">{text}</pre>
</Layout>
}

/**
* Robust stringification of any value, including json and bigints.
*/
function stringify(value: any): string | undefined {
if (typeof value === 'string') return value
if (typeof value === 'number') return value.toLocaleString()
if (Array.isArray(value)) return `[\n${value.map(v => indent(stringify(v), 2)).join(',\n')}\n]`
if (value === null || value === undefined) return JSON.stringify(value)
if (value instanceof Date) return value.toISOString()
if (typeof value === 'object') {
return `{${Object.entries(value).map(([k, v]) => `${k}: ${stringify(v)}`).join(', ')}}`
}
return value.toString()
}

function indent(text: string | undefined, spaces: number) {
return text?.split('\n').map(line => ' '.repeat(spaces) + line).join('\n')
}
3 changes: 1 addition & 2 deletions src/components/File.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export default function File({ file }: FileProps) {
// File path from url
const path = file.split('/')
const fileName = path.at(-1)

const isUrl = file.startsWith('http://') || file.startsWith('https://')

return <Layout progress={progress} error={error} title={fileName}>
Expand All @@ -35,6 +34,6 @@ export default function File({ file }: FileProps) {
</div>
</nav>

<Viewer content={file} setProgress={setProgress} setError={setError} />
<Viewer file={file} setProgress={setProgress} setError={setError} />
</Layout>
}
18 changes: 9 additions & 9 deletions src/components/Viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import TableView from './viewers/ParquetView.js'
import TextView from './viewers/TextView.js'

interface ViewerProps {
content: string
file: string
setError: (error: Error) => void
setProgress: (progress: number) => void
}
Expand All @@ -14,18 +14,18 @@ interface ViewerProps {
* Get a viewer for a file.
* Chooses viewer based on content type.
*/
export default function Viewer({ content, setError, setProgress }: ViewerProps) {
if (content.endsWith('.md')) {
return <MarkdownView content={content} setError={setError} />
} else if (content.endsWith('.parquet')) {
return <TableView content={content} setError={setError} setProgress={setProgress} />
} else if (imageTypes.some(type => content.endsWith(type))) {
return <ImageView content={content} setError={setError} />
export default function Viewer({ file, setError, setProgress }: ViewerProps) {
if (file.endsWith('.md')) {
return <MarkdownView file={file} setError={setError} />
} else if (file.endsWith('.parquet')) {
return <TableView file={file} setError={setError} setProgress={setProgress} />
} else if (imageTypes.some(type => file.endsWith(type))) {
return <ImageView file={file} setError={setError} />
}

// Default to text viewer
return <TextView
content={content}
file={file}
setError={setError}
setProgress={setProgress} />
}
28 changes: 28 additions & 0 deletions src/components/viewers/ContentHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React, { ReactNode } from 'react'
import { getFileSize } from '../../files.js'

interface ContentHeaderProps {
content?: { fileSize?: number }
headers?: ReactNode
children?: ReactNode
}

export default function ContentHeader({ content, headers, children }: ContentHeaderProps) {
return <div className='viewer'>
<div className='view-header'>
<span title={content?.fileSize?.toLocaleString() + ' bytes'}>
{getFileSize(content)}
</span>
{headers}
</div>
{children}
</div>
}

/**
* Parse the content-length header from a fetch response.
*/
export function parseFileSize(headers: Headers): number | undefined {
const contentLength = headers.get('content-length')
return contentLength ? Number(contentLength) : undefined
}
33 changes: 20 additions & 13 deletions src/components/viewers/ImageView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'
import ContentHeader, { parseFileSize } from './ContentHeader.js'

enum LoadingState {
NotLoaded,
Expand All @@ -7,19 +8,24 @@ enum LoadingState {
}

interface ViewerProps {
content: string
file: string
setError: (error: Error) => void
}

interface Content {
dataUri: string
fileSize?: number
}

/**
* Image viewer component.
*/
export default function ImageView({ content, setError }: ViewerProps) {
export default function ImageView({ file, setError }: ViewerProps) {
const [loading, setLoading] = useState(LoadingState.NotLoaded)
const [dataUri, setDataUri] = useState<string>()
const [content, setContent] = useState<Content>()

const isUrl = content.startsWith('http://') || content.startsWith('https://')
const url = isUrl ? content : '/api/store/get?key=' + content
const isUrl = file.startsWith('http://') || file.startsWith('https://')
const url = isUrl ? file : '/api/store/get?key=' + file

useEffect(() => {
async function loadContent() {
Expand All @@ -28,8 +34,9 @@ export default function ImageView({ content, setError }: ViewerProps) {
const arrayBuffer = await res.arrayBuffer()
// base64 encode and display image
const b64 = arrayBufferToBase64(arrayBuffer)
const dataUri = `data:${contentType(content)};base64,${b64}`
setDataUri(dataUri)
const dataUri = `data:${contentType(url)};base64,${b64}`
const fileSize = parseFileSize(res.headers)
setContent({ dataUri, fileSize })
} catch (error) {
setError(error as Error)
} finally {
Expand All @@ -43,14 +50,14 @@ export default function ImageView({ content, setError }: ViewerProps) {
loadContent()
return LoadingState.Loading
})
}, [content, loading, setError])
}, [url, loading, setError])

return <div className='viewer'>
{dataUri && <img
alt={content}
return <ContentHeader content={content}>
{content?.dataUri && <img
alt={file}
className='image'
src={dataUri} />}
</div>
src={content.dataUri} />}
</ContentHeader>
}

/**
Expand Down
19 changes: 10 additions & 9 deletions src/components/viewers/MarkdownView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react'
import { useEffect, useState } from 'react'
import Markdown from '../Markdown.js'
import ContentHeader from './ContentHeader.js'

enum LoadingState {
NotLoaded,
Expand All @@ -9,19 +10,19 @@ enum LoadingState {
}

interface ViewerProps {
content: string
file: string
setError: (error: Error) => void
}

/**
* Markdown viewer component.
*/
export default function MarkdownView({ content, setError }: ViewerProps) {
export default function MarkdownView({ file, setError }: ViewerProps) {
const [loading, setLoading] = useState(LoadingState.NotLoaded)
const [text, setText] = useState<string>('')
const [text, setText] = useState<string | undefined>()

const isUrl = content.startsWith('http://') || content.startsWith('https://')
const url = isUrl ? content : '/api/store/get?key=' + content
const isUrl = file.startsWith('http://') || file.startsWith('https://')
const url = isUrl ? file : '/api/store/get?key=' + file

useEffect(() => {
async function loadContent() {
Expand All @@ -42,9 +43,9 @@ export default function MarkdownView({ content, setError }: ViewerProps) {
loadContent()
return LoadingState.Loading
})
}, [content, loading, setError])
}, [url, loading, setError])

return <div className='viewer'>
<Markdown className='markdown' text={text} />
</div>
return <ContentHeader content={{ fileSize: text?.length }}>
<Markdown className='markdown' text={text || ''} />
</ContentHeader>
}
43 changes: 28 additions & 15 deletions src/components/viewers/ParquetView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import HighTable, { DataFrame } from 'hightable'
import React, { useCallback, useEffect, useState } from 'react'
import { parquetDataFrame } from '../../tableProvider.js'
import { asyncBufferFrom, parquetDataFrame } from '../../tableProvider.js'
import { Spinner } from '../Layout.js'
import ContentHeader from './ContentHeader.js'

enum LoadingState {
NotLoaded,
Expand All @@ -10,27 +11,35 @@ enum LoadingState {
}

interface ViewerProps {
content: string
file: string
setProgress: (progress: number) => void
setError: (error: Error) => void
}

interface Content {
dataframe: DataFrame
fileSize?: number
}

/**
* Parquet file viewer
*/
export default function ParquetView({ content, setProgress, setError }: ViewerProps) {
export default function ParquetView({ file, setProgress, setError }: ViewerProps) {
const [loading, setLoading] = useState<LoadingState>(LoadingState.NotLoaded)
const [dataframe, setDataframe] = useState<DataFrame>()
const [content, setContent] = useState<Content>()

const isUrl = content.startsWith('http://') || content.startsWith('https://')
const url = isUrl ? content : '/api/store/get?key=' + content
const isUrl = file.startsWith('http://') || file.startsWith('https://')
const url = isUrl ? file : '/api/store/get?key=' + file

useEffect(() => {
async function loadParquetDataFrame() {
try {
setProgress(0.5)
const df = await parquetDataFrame(url)
setDataframe(df)
setProgress(0.33)
const asyncBuffer = await asyncBufferFrom(url)
setProgress(0.66)
const dataframe = await parquetDataFrame(asyncBuffer)
const fileSize = asyncBuffer.byteLength
setContent({ dataframe, fileSize })
} catch (error) {
setError(error as Error)
} finally {
Expand All @@ -45,15 +54,19 @@ export default function ParquetView({ content, setProgress, setError }: ViewerPr
}, [])

const onDoubleClickCell = useCallback((row: number, col: number) => {
location.href = '/files?key=' + content + '&row=' + row + '&col=' + col
}, [content])
location.href = '/files?key=' + file + '&row=' + row + '&col=' + col
}, [file])

return <>
{dataframe && <HighTable
data={dataframe}
const headers = <>
{content?.dataframe && <span>{content.dataframe.numRows.toLocaleString()} rows</span>}
</>

return <ContentHeader content={content} headers={headers}>
{content?.dataframe && <HighTable
data={content.dataframe}
onDoubleClickCell={onDoubleClickCell}
onError={setError} />}

{loading && <Spinner className='center' />}
</>
</ContentHeader>
}
Loading

0 comments on commit 7f91d98

Please sign in to comment.