127 lines
4 KiB
TypeScript
127 lines
4 KiB
TypeScript
"use client";
|
||
|
||
interface Props {
|
||
bucketLabels: string[];
|
||
shares: number[]; // длина = bucketLabels.length, сумма ≈ 100
|
||
recommended: number[]; // оригинальные share_pct из API
|
||
onChange: (next: number[]) => void;
|
||
onReset: () => void;
|
||
}
|
||
|
||
/**
|
||
* Auto-rescale slider group. Когда пользователь двигает один слайдер до X%,
|
||
* остальные пересчитываются пропорционально к (100 - X)%, сохраняя их
|
||
* относительные доли. Если у других нет массы (sum=0) — распределяем поровну.
|
||
*/
|
||
export function RecommendShareSliders({
|
||
bucketLabels,
|
||
shares,
|
||
recommended,
|
||
onChange,
|
||
onReset,
|
||
}: Props) {
|
||
const handle = (idx: number, raw: number) => {
|
||
const v = Math.max(0, Math.min(100, raw));
|
||
const next = [...shares];
|
||
next[idx] = v;
|
||
const remaining = 100 - v;
|
||
const others = shares.filter((_, i) => i !== idx);
|
||
const otherSum = others.reduce((a, b) => a + b, 0);
|
||
if (otherSum <= 0) {
|
||
const each = remaining / others.length;
|
||
for (let i = 0; i < next.length; i++) {
|
||
if (i !== idx) next[i] = each;
|
||
}
|
||
} else {
|
||
for (let i = 0; i < next.length; i++) {
|
||
if (i !== idx) next[i] = (shares[i] * remaining) / otherSum;
|
||
}
|
||
}
|
||
// round to 1 decimal, fix sum-rounding drift on the changed slider
|
||
const rounded = next.map((x) => Math.round(x * 10) / 10);
|
||
const drift = 100 - rounded.reduce((a, b) => a + b, 0);
|
||
rounded[idx] = Math.round((rounded[idx] + drift) * 10) / 10;
|
||
onChange(rounded);
|
||
};
|
||
|
||
const dirty = shares.some(
|
||
(s, i) => Math.abs(s - (recommended[i] ?? 0)) > 0.05,
|
||
);
|
||
|
||
return (
|
||
<div>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
marginBottom: 8,
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 13, color: "#5b6066" }}>
|
||
Сумма: {shares.reduce((a, b) => a + b, 0).toFixed(1)} %
|
||
</span>
|
||
<button
|
||
onClick={onReset}
|
||
disabled={!dirty}
|
||
style={{
|
||
padding: "4px 10px",
|
||
fontSize: 12,
|
||
border: "1px solid #d1d5db",
|
||
borderRadius: 12,
|
||
background: dirty ? "#fff" : "#f6f7f9",
|
||
color: dirty ? "#1f2937" : "#9ca3af",
|
||
cursor: dirty ? "pointer" : "not-allowed",
|
||
}}
|
||
>
|
||
↻ Сбросить к рекомендации
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ display: "grid", gap: 12 }}>
|
||
{bucketLabels.map((label, i) => {
|
||
const recValue = recommended[i] ?? 0;
|
||
const v = shares[i] ?? 0;
|
||
const delta = v - recValue;
|
||
return (
|
||
<div
|
||
key={label}
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "140px 1fr 90px",
|
||
gap: 12,
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 13, fontWeight: 500 }}>{label}</span>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={100}
|
||
step={0.5}
|
||
value={v}
|
||
onChange={(e) => handle(i, Number(e.target.value))}
|
||
style={{ width: "100%" }}
|
||
/>
|
||
<div style={{ textAlign: "right", fontSize: 13 }}>
|
||
<span style={{ fontWeight: 600 }}>{v.toFixed(1)}%</span>
|
||
{Math.abs(delta) > 0.1 ? (
|
||
<span
|
||
style={{
|
||
marginLeft: 6,
|
||
fontSize: 11,
|
||
color: delta > 0 ? "#0a7a3a" : "#b3261e",
|
||
}}
|
||
>
|
||
{delta > 0 ? "+" : ""}
|
||
{delta.toFixed(1)}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|