Files
Dune/components/battle-wheel.tsx
T
2026-09-08 07:37:52 +10:00

187 lines
5.2 KiB
TypeScript

'use client';
import { useId } from 'react';
import styles from './battle-wheel.module.css';
export type BattleWheelProps = {
value: number;
onChange: (value: number) => void;
max: number;
step: number;
disabled?: boolean;
id?: string;
};
const point = (angle: number, radius: number) => ({
x: 100 + Math.sin((angle * Math.PI) / 180) * radius,
y: 100 - Math.cos((angle * Math.PI) / 180) * radius,
});
/** The parent supplies the dial limits and labels the numeric input by its ID. */
export function BattleWheel({
value,
onChange,
max,
step,
disabled = false,
id = 'forces-dialed',
}: BattleWheelProps) {
const graphicId = useId();
const rimId = `${graphicId}-rim`;
const faceId = `${graphicId}-face`;
const fraction = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0;
const angle = -135 + fraction * 270;
// This caps decorative detail only; the native controls keep the supplied step.
const divisions = Math.min(80, Math.max(1, Math.ceil(max / step)));
const labels = [
...new Set(
[0, 0.25, 0.5, 0.75, 1].map((position) =>
Math.min(max, Math.round((max * position) / step) * step),
),
),
];
return (
<div className={styles.root} data-disabled={disabled || undefined}>
<svg
className={styles.wheel}
viewBox="0 0 200 200"
aria-hidden="true"
focusable="false"
>
<defs>
<linearGradient id={rimId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#e3c68a" />
<stop offset="0.35" stopColor="#756344" />
<stop offset="0.65" stopColor="#302d24" />
<stop offset="1" stopColor="#b29966" />
</linearGradient>
<radialGradient id={faceId} cx="40%" cy="28%" r="75%">
<stop offset="0" stopColor="#30362b" />
<stop offset="1" stopColor="#131810" />
</radialGradient>
</defs>
<circle cx="100" cy="102" r="96" fill="#080b07" opacity="0.6" />
<circle cx="100" cy="100" r="95" fill={`url(#${rimId})`} />
<circle cx="100" cy="100" r="91" fill="#181d15" />
<circle
cx="100"
cy="100"
r="87"
fill="none"
stroke="#736344"
strokeWidth="0.6"
/>
{Array.from({ length: divisions + 1 }, (_, index) => {
const position = index / divisions;
const tickAngle = -135 + position * 270;
const major = index % Math.max(1, Math.round(divisions / 4)) === 0;
const outer = point(tickAngle, 83);
const inner = point(tickAngle, major ? 73 : 78);
return (
<line
key={index}
x1={inner.x}
y1={inner.y}
x2={outer.x}
y2={outer.y}
stroke={position <= fraction ? '#e3c68a' : '#777762'}
strokeWidth={major ? 1.7 : 0.8}
/>
);
})}
{labels.map((mark) => {
const label = point(-135 + (max > 0 ? mark / max : 0) * 270, 62);
return (
<text
key={mark}
x={label.x}
y={label.y}
fill="#d1c4a4"
fontSize="12"
textAnchor="middle"
dominantBaseline="central"
>
{Number(mark.toFixed(2))}
</text>
);
})}
<circle
cx="100"
cy="100"
r="44"
fill={`url(#${faceId})`}
stroke="#89764d"
strokeWidth="1"
/>
<circle
cx="100"
cy="100"
r="40"
fill="none"
stroke="#c6ad76"
strokeOpacity="0.15"
/>
<path
d="M 96 29 L 100 18 L 104 29 Z"
fill="#f2d79b"
stroke="#171b12"
strokeWidth="0.8"
transform={`rotate(${angle} 100 100)`}
/>
<text
x="100"
y="101"
textAnchor="middle"
dominantBaseline="central"
className={styles.chosen}
>
{value}
</text>
<text
x="100"
y="124"
textAnchor="middle"
fill="#c0b290"
fontSize="9"
letterSpacing="1.6"
>
DIALED
</text>
<path d="M 85 173 H 115" stroke="#a28b5e" strokeWidth="1" />
<circle cx="100" cy="180" r="1.5" fill="#a28b5e" />
</svg>
<div className={styles.controls}>
<div className={styles.slider}>
<input
className={styles.range}
type="range"
min={0}
max={max}
step={step}
value={value}
disabled={disabled}
aria-label="Forces dialed slider"
onChange={(event) => onChange(Number(event.target.value))}
/>
<div className={styles.bounds} aria-hidden="true">
<span>0</span>
<span>{max}</span>
</div>
</div>
<input
className={styles.number}
id={id}
type="number"
min={0}
max={max}
step={step}
value={value}
disabled={disabled}
onChange={(event) => onChange(Number(event.target.value))}
/>
</div>
</div>
);
}