Skip to content

Commit

Permalink
feat(www): add initial tracespace view page
Browse files Browse the repository at this point in the history
  • Loading branch information
mcous committed Mar 3, 2023
1 parent ef2a3f0 commit 6ecece4
Show file tree
Hide file tree
Showing 26 changed files with 1,016 additions and 4 deletions.
18 changes: 18 additions & 0 deletions www/src/components/file-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type {ComponentChildren} from 'preact'
import type {StylableComponentProps} from './types'

export interface FileInputProps extends StylableComponentProps {
children: ComponentChildren
onChange: JSX.DOMAttributes<HTMLInputElement>['onChange']
}

export function FileInput(props: FileInputProps): JSX.Element {
const {children, onChange, ...styleAttributes} = props

return (
<label cursor="pointer" {...styleAttributes}>
{children}
<input onChange={onChange} sr="only" type="file" multiple />
</label>
)
}
30 changes: 30 additions & 0 deletions www/src/components/icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type {IconDefinition} from '@fortawesome/fontawesome-svg-core'

import type {StylableComponentProps} from './types'

export interface IconProps extends StylableComponentProps {
data: IconDefinition
}

export function Icon(props: IconProps) {
const {data, ...styleAttributes} = props
const [width, height, _ligatures, _unicode, pathData] = data.icon
const viewBox = `0 0 ${width} ${height}`
const path = typeof pathData === 'string' ? pathData : pathData.join('')

return (
<svg
viewBox={viewBox}
focusable="false"
role="img"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
fill="currentColor"
w="1em"
h="1em"
{...styleAttributes}
>
<path d={path} />
</svg>
)
}
35 changes: 35 additions & 0 deletions www/src/components/log-slider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type {StylableComponentProps} from './types'

export interface Props extends StylableComponentProps {
value: number
valueText: string
min: number
max: number
onChange: (value: number) => unknown
}

export function LogSlider(props: Props): JSX.Element {
const {value, valueText, min, max, onChange, ...styleProps} = props

return (
<input
cursor="grab"
type="range"
value={Math.log(value)}
aria-valuetext={valueText}
step="any"
min={Math.log(min)}
max={Math.log(max)}
appearance="none slider-thumb:none"
text="white dark:dark-800"
bg="dark-800 dark:white"
border="rounded-full"
onChange={event => {
if (event.target instanceof HTMLInputElement) {
onChange(Math.E ** Number(event.target.value))
}
}}
{...styleProps}
/>
)
}
1 change: 1 addition & 0 deletions www/src/components/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type {AttributifyAttributes as StylableComponentProps} from 'windicss/types/jsx'
213 changes: 213 additions & 0 deletions www/src/components/use-pan-zoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import {useRef, useEffect} from 'preact/hooks'
import type {Ref} from 'preact/hooks'

export interface PanZoomOptions {
initialScale?: number
minScale?: number
maxScale?: number
onChange?: (state: PanZoomState) => unknown
}

export type PanZoomState = [scale: number, panX: number, panY: number]

export type PanZoom = [ref: Ref<SVGGElement>, zoom?: PanZoomer]

export function usePanZoom(options: PanZoomOptions = {}): PanZoom {
const viewportRef = useRef<SVGGElement>(null)
const panZoomerRef = useRef<PanZoomer>()

useEffect(() => {
if (viewportRef.current !== null) {
const [cleanup, panZoomer] = initializePanZoom(
viewportRef.current,
options
)
panZoomerRef.current = panZoomer
return cleanup
}
}, [])

return [viewportRef, panZoomerRef.current]
}

interface PanZoomer {
container: SVGSVGElement
options: PanZoomOptions
state: PanZoomState
create(container: SVGSVGElement, options: PanZoomOptions): PanZoomer
reset(): void
pan(deltaX: number, deltaY: number): void
zoom(deltaZ: number, centerX?: number, centerY?: number): void
zoomTo(scale: number, centerX?: number, centerY?: number): void
}

const PanZoomerPrototype: PanZoomer = {
container: undefined as unknown as SVGSVGElement,
options: undefined as unknown as PanZoomOptions,
state: undefined as unknown as PanZoomState,

create(container: SVGSVGElement, options: PanZoomOptions): PanZoomer {
const panZoomer = Object.assign(Object.create(PanZoomerPrototype), {
container,
options,
state: [1, 0, 0],
})

return panZoomer.reset()
},

reset(): PanZoomer {
const initialScale = this.options.initialScale ?? 1
const initialPanX = 0.5 * this.container.clientWidth * (1 - initialScale)
const initialPanY = 0.5 * this.container.clientHeight * (1 - initialScale)
const state = [initialScale, initialPanX, initialPanY]

Object.assign(this, {state})
this.options.onChange?.(this.state)
return this
},

pan(deltaX: number, deltaY: number): void {
this.state[1] += deltaX
this.state[2] += deltaY

this.options.onChange?.(this.state)
},

zoom(deltaZ: number, centerX?: number, centerY?: number): void {
const [scale] = this.state
this.zoomTo(scale * (1 - deltaZ), centerX, centerY)
},

zoomTo(scale: number, centerX?: number, centerY?: number): void {
centerX = centerX ?? 0.5 * this.container.clientWidth
centerY = centerY ?? 0.5 * this.container.clientHeight

const [previousScale, panX, panY] = this.state
const nextScale = clamp(scale, this.options.minScale, this.options.maxScale)

const zoom = nextScale / previousScale
const originX = centerX - panX
const originY = centerY - panY

this.state[0] = nextScale
this.state[1] += originX * (1 - zoom)
this.state[2] += originY * (1 - zoom)

this.options.onChange?.(this.state)
},
}

function initializePanZoom(
viewport: SVGGElement,
options: PanZoomOptions
): [cleanup: () => void, panZoomer: PanZoomer] {
const updateViewport = throttle((state: PanZoomState) => {
const [scale, panX, panY] = state
viewport.style.transform = `translate(${panX}px,${panY}px) scale(${scale})`
options.onChange?.([scale, panX, panY])
})

const container = viewport.parentElement as unknown as SVGSVGElement
const panZoomer = PanZoomerPrototype.create(container, {
...options,
onChange: updateViewport,
})

let mouseLocation: [x: number, y: number] | undefined

const handleMouseDown = (event: MouseEvent) => {
// eslint-disable-next-line no-bitwise
if ((event.buttons & 1) === 1) {
mouseLocation = [event.clientX, event.clientY]
}
}

const handleMouseUp = () => {
mouseLocation = undefined
}

const handleMouseMove = throttle((event: MouseEvent) => {
if (mouseLocation !== undefined) {
const movementX = event.clientX - mouseLocation[0]
const movementY = event.clientY - mouseLocation[1]

mouseLocation[0] = event.clientX
mouseLocation[1] = event.clientY
panZoomer.pan(movementX, movementY)
}
})

const handleWheel = (event: WheelEvent) => {
panZoomer.zoom(event.deltaY / 1000, event.clientX, event.clientY)
event.preventDefault()
}

const cleanup = () => {
updateViewport.cancel()
handleMouseMove.cancel()
container.removeEventListener('mousedown', handleMouseDown)
container.removeEventListener('mouseenter', handleMouseDown)
container.removeEventListener('mouseup', handleMouseUp)
container.removeEventListener('mouseleave', handleMouseUp)
container.removeEventListener('mousemove', handleMouseMove)
container.removeEventListener('wheel', handleWheel)
}

container.addEventListener('mousedown', handleMouseDown, {passive: true})
container.addEventListener('mouseenter', handleMouseDown, {passive: true})
container.addEventListener('mouseup', handleMouseUp, {passive: true})
container.addEventListener('mouseleave', handleMouseUp, {passive: true})
container.addEventListener('mousemove', handleMouseMove, {passive: true})
container.addEventListener('wheel', handleWheel, {passive: false})

return [cleanup, panZoomer]
}

export type Callback<A extends any[]> = (...args: A) => void

export interface ThrottledCallback<A extends any[]> extends Callback<A> {
cancel: () => void
}

function throttle<ArgsType extends any[]>(
callback: Callback<ArgsType>
): ThrottledCallback<ArgsType> {
let requestId: number | undefined
let latestArgs: ArgsType | undefined

return Object.assign(throttledCallback, {cancel})

function throttledCallback(...args: ArgsType): void {
latestArgs = args

if (requestId === undefined) {
requestId = requestAnimationFrame(doCallback)
}
}

function doCallback() {
if (latestArgs !== undefined) {
callback(...latestArgs)
requestId = undefined
latestArgs = undefined
}
}

function cancel() {
if (requestId !== undefined) {
cancelAnimationFrame(requestId)
requestId = undefined
}
}
}

function clamp(
value: number,
min: number | undefined,
max: number | undefined
): number {
if (min !== undefined && value < min) return min
if (max !== undefined && value > max) return max
return value
}
61 changes: 61 additions & 0 deletions www/src/dark-mode/__tests__/use-dark-mode.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// @vitest-environment jsdom
import {describe, it, afterEach, expect} from 'vitest'
import {renderHook, act, cleanup} from '@testing-library/preact'

import {useDarkMode as subject} from '..'

describe('useDarkMode', () => {
afterEach(async () => {
document.querySelector('html')?.classList.remove('dark')
cleanup()
})

it('should initialize to false by default', () => {
const {result} = renderHook(() => subject())
expect(result.current?.[0]).toEqual(false)
})

it('should initialize to true if dark class present', () => {
document.querySelector('html')?.classList.add('dark')

const {result} = renderHook(() => subject())

expect(result.current?.[0]).toEqual(true)
})

it('should toggle dark mode', async () => {
const {result} = renderHook(() => subject())

expect(result.current?.[0]).toEqual(false)

await act(() => {
result.current?.[1]()
})

expect(result.current?.[0]).toEqual(true)

await act(() => {
result.current?.[1]()
})

expect(result.current?.[0]).toEqual(false)
})

it('should add html class and persist to localstorage', async () => {
const {result} = renderHook(() => subject())

await act(() => {
result.current?.[1]()
})

expect(document.querySelector('html')).toHaveClass('dark')
expect(localStorage.getItem('tracespace:darkModeEnabled')).toEqual('true')

await act(() => {
result.current?.[1]()
})

expect(document.querySelector('html')).not.toHaveClass('dark')
expect(localStorage.getItem('tracespace:darkModeEnabled')).toEqual('false')
})
})
26 changes: 26 additions & 0 deletions www/src/dark-mode/dark-mode-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {useState, useEffect} from 'preact/hooks'
import {faSun} from '@fortawesome/free-solid-svg-icons/faSun'
import {faMoon} from '@fortawesome/free-solid-svg-icons/faMoon'

import {Icon} from '../components/icon'
import type {StylableComponentProps} from '../components/types'
import {useDarkMode} from './use-dark-mode'

export interface DarkModeToggleProps extends StylableComponentProps {}

export function DarkModeToggle(props: DarkModeToggleProps): JSX.Element {
const [isDark, toggleIsDark] = useDarkMode()
const iconData = isDark ? faMoon : faSun
const labelText = `Switch to ${isDark ? 'light' : 'dark'} mode`

return (
<button
title={labelText}
aria-label={labelText}
onClick={toggleIsDark}
{...props}
>
<Icon data={iconData} />
</button>
)
}
2 changes: 2 additions & 0 deletions www/src/dark-mode/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './dark-mode-toggle'
export * from './use-dark-mode'

0 comments on commit 6ecece4

Please sign in to comment.