- Implemented Form301 component for displaying OSHA Form 301 equivalent, including employee and case information. - Created Osha component to manage OSHA recordkeeping, including tabs for 300 Log, 300A Summary, Hours & rates, and Submit to OSHA. - Added functionality for printing and downloading CSV files for OSHA submissions. - Integrated loading and error handling states for data fetching.
102 lines
4.1 KiB
TypeScript
102 lines
4.1 KiB
TypeScript
import { useState } from 'react'
|
|
import { Tooltip } from './ChartFrame'
|
|
import { formatNumber, niceTicks } from './scale'
|
|
import { useWidth } from './useWidth'
|
|
|
|
export interface Series {
|
|
key: string
|
|
label: string
|
|
color: string
|
|
}
|
|
|
|
interface Props {
|
|
data: { label: string; values: Record<string, number> }[]
|
|
series: Series[]
|
|
height?: number
|
|
ariaLabel: string
|
|
}
|
|
|
|
const PAD = { top: 8, right: 8, bottom: 26, left: 32 }
|
|
const GAP = 2 // surface gap between stacked segments
|
|
const RADIUS = 4
|
|
|
|
/** Rounded top on the data end, square at the baseline. */
|
|
function columnPath(x: number, y: number, w: number, h: number, roundTop: boolean) {
|
|
const r = roundTop ? Math.min(RADIUS, h, w / 2) : 0
|
|
return `M${x},${y + h}V${y + r}${r ? `Q${x},${y} ${x + r},${y}` : ''}H${x + w - r}${r ? `Q${x + w},${y} ${x + w},${y + r}` : ''}V${y + h}Z`
|
|
}
|
|
|
|
export function StackedColumns({ data, series, height = 220, ariaLabel }: Props) {
|
|
const [ref, width] = useWidth<HTMLDivElement>()
|
|
const [hover, setHover] = useState<number | null>(null)
|
|
const plotW = width - PAD.left - PAD.right
|
|
const plotH = height - PAD.top - PAD.bottom
|
|
const totals = data.map((d) => series.reduce((sum, s) => sum + (d.values[s.key] ?? 0), 0))
|
|
const ticks = niceTicks(Math.max(...totals, 1))
|
|
const max = ticks.at(-1)!
|
|
const band = plotW / Math.max(1, data.length)
|
|
const barW = Math.min(24, band * 0.6)
|
|
const y = (v: number) => PAD.top + plotH - (v / max) * plotH
|
|
const labelEvery = Math.ceil(data.length / Math.max(1, Math.floor(plotW / 56)))
|
|
|
|
return (
|
|
<div ref={ref} className="relative" onPointerLeave={() => setHover(null)}>
|
|
<svg width={width} height={height} role="img" aria-label={ariaLabel} className="block">
|
|
{ticks.map((t) => (
|
|
<g key={t}>
|
|
<line x1={PAD.left} x2={width - PAD.right} y1={y(t)} y2={y(t)} stroke={t === 0 ? 'var(--color-chart-axis)' : 'var(--color-chart-grid)'} strokeWidth={1} shapeRendering="crispEdges" />
|
|
<text x={PAD.left - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize={10} fill="var(--color-chart-label)" className="tabular-nums">
|
|
{formatNumber(t)}
|
|
</text>
|
|
</g>
|
|
))}
|
|
{data.map((d, i) => {
|
|
const x = PAD.left + i * band + (band - barW) / 2
|
|
let base = 0
|
|
const visible = series.filter((s) => (d.values[s.key] ?? 0) > 0)
|
|
return (
|
|
<g key={d.label} opacity={hover === null || hover === i ? 1 : 0.55}>
|
|
{visible.map((s, j) => {
|
|
const v = d.values[s.key]
|
|
const top = y(base + v)
|
|
const bottom = y(base)
|
|
base += v
|
|
const isTop = j === visible.length - 1
|
|
const h = Math.max(0, bottom - top - (j > 0 ? GAP : 0))
|
|
return <path key={s.key} d={columnPath(x, top, barW, h, isTop)} fill={s.color} />
|
|
})}
|
|
{(i % labelEvery === 0 || i === data.length - 1) && (
|
|
<text x={x + barW / 2} y={height - 8} textAnchor="middle" fontSize={10} fill="var(--color-chart-label)">
|
|
{d.label}
|
|
</text>
|
|
)}
|
|
<rect
|
|
x={PAD.left + i * band}
|
|
y={PAD.top}
|
|
width={band}
|
|
height={plotH}
|
|
fill="transparent"
|
|
tabIndex={0}
|
|
aria-label={`${d.label}: ${series.map((s) => `${d.values[s.key] ?? 0} ${s.label}`).join(', ')}`}
|
|
onPointerEnter={() => setHover(i)}
|
|
onFocus={() => setHover(i)}
|
|
onBlur={() => setHover(null)}
|
|
className="outline-none"
|
|
/>
|
|
</g>
|
|
)
|
|
})}
|
|
</svg>
|
|
{hover !== null && (
|
|
<Tooltip
|
|
x={PAD.left + hover * band + band / 2}
|
|
y={y(totals[hover])}
|
|
containerWidth={width}
|
|
title={`${data[hover].label} · ${formatNumber(totals[hover])} total`}
|
|
rows={[...series].reverse().map((s) => ({ label: s.label, value: formatNumber(data[hover].values[s.key] ?? 0), color: s.color }))}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|