- 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.
66 lines
3.0 KiB
TypeScript
66 lines
3.0 KiB
TypeScript
import { useState } from 'react'
|
|
import { Tooltip } from './ChartFrame'
|
|
import { useWidth } from './useWidth'
|
|
|
|
const STEPS = ['var(--color-seq-100)', 'var(--color-seq-200)', 'var(--color-seq-300)', 'var(--color-seq-400)', 'var(--color-seq-500)', 'var(--color-seq-600)', 'var(--color-seq-700)']
|
|
|
|
/** Grid of counts in one hue, light to dark; empty cells stay near the surface. */
|
|
export function Heatmap({ grid, rowLabels, colLabels, ariaLabel, describe }: { grid: number[][]; rowLabels: string[]; colLabels: string[]; ariaLabel: string; describe: (row: number, col: number, value: number) => string }) {
|
|
const [ref, width] = useWidth<HTMLDivElement>()
|
|
const [hover, setHover] = useState<{ r: number; c: number } | null>(null)
|
|
const max = Math.max(1, ...grid.flat())
|
|
const step = (value: number) => (value === 0 ? 'var(--color-line-soft)' : STEPS[Math.min(STEPS.length - 1, Math.floor((value / max) * (STEPS.length - 1)))])
|
|
const labelW = 34
|
|
const cell = Math.max(10, Math.floor((width - labelW) / colLabels.length))
|
|
const height = rowLabels.length * cell + 18
|
|
|
|
return (
|
|
<div ref={ref} className="relative" onPointerLeave={() => setHover(null)}>
|
|
<svg width={width} height={height} role="img" aria-label={ariaLabel} className="block">
|
|
{rowLabels.map((label, r) => (
|
|
<text key={label} x={labelW - 6} y={r * cell + cell / 2} dy="0.32em" textAnchor="end" fontSize={10} fill="var(--color-chart-label)">
|
|
{label}
|
|
</text>
|
|
))}
|
|
{colLabels.map((label, c) =>
|
|
c % 3 === 0 ? (
|
|
<text key={c} x={labelW + c * cell + cell / 2} y={rowLabels.length * cell + 12} textAnchor="middle" fontSize={10} fill="var(--color-chart-label)">
|
|
{label}
|
|
</text>
|
|
) : null,
|
|
)}
|
|
{grid.map((row, r) =>
|
|
row.map((value, c) => (
|
|
<rect
|
|
key={`${r}-${c}`}
|
|
x={labelW + c * cell + 1}
|
|
y={r * cell + 1}
|
|
width={cell - 2}
|
|
height={cell - 2}
|
|
rx={2}
|
|
fill={step(value)}
|
|
stroke={hover?.r === r && hover.c === c ? 'var(--color-ink)' : 'none'}
|
|
strokeWidth={1.5}
|
|
tabIndex={value ? 0 : -1}
|
|
aria-label={describe(r, c, value)}
|
|
onPointerEnter={() => setHover({ r, c })}
|
|
onFocus={() => setHover({ r, c })}
|
|
className="outline-none"
|
|
/>
|
|
)),
|
|
)}
|
|
</svg>
|
|
<div className="mt-2 flex items-center gap-2 text-3xs text-muted" aria-hidden>
|
|
<span>Fewer</span>
|
|
{STEPS.map((color) => (
|
|
<i key={color} className="h-2.5 w-5 rounded-[2px]" style={{ background: color }} />
|
|
))}
|
|
<span>More ({max} max)</span>
|
|
</div>
|
|
{hover && (
|
|
<Tooltip x={labelW + hover.c * cell + cell} y={hover.r * cell} containerWidth={width} title={describe(hover.r, hover.c, grid[hover.r][hover.c]).split(':')[0]} rows={[{ label: grid[hover.r][hover.c] === 1 ? 'incident' : 'incidents', value: String(grid[hover.r][hover.c]) }]} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|