Files
open-safetylog-ehs/src/components/charts/Heatmap.tsx
T
jayjennings 61c876edec
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
Add OSHA Form 301 and Osha page components
- 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.
2026-10-06 13:09:57 -07:00

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>
)
}