Skip to content

Commit 64021a0

Browse files
committed
feat: new components
1 parent 7a95d13 commit 64021a0

8 files changed

Lines changed: 1686 additions & 2 deletions

File tree

components/mdx/bar-chart.tsx

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
"use client";
2+
3+
import { Children, useState, useRef, type ReactNode } from "react";
4+
import { motion, useInView } from "motion/react";
5+
import { safeEval } from "@/lib/math/safe-eval";
6+
7+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
8+
type AnyElement = { props: Record<string, any> };
9+
10+
/* ═══════════════════════════════════════════════════
11+
Data-carrier components
12+
═══════════════════════════════════════════════════ */
13+
14+
interface BarProps {
15+
label: string;
16+
/** Static numeric value. */
17+
value?: number;
18+
/** Reactive expression string (evaluated with slider vars). */
19+
expr?: string;
20+
color?: string;
21+
}
22+
23+
export function Bar(_: BarProps) {
24+
return null;
25+
}
26+
27+
// Re-import Slider type for extraction
28+
interface SliderDef {
29+
name: string;
30+
min?: number;
31+
max?: number;
32+
default?: number;
33+
step?: number;
34+
label?: string;
35+
}
36+
37+
/* ═══════════════════════════════════════════════════
38+
BarChart component
39+
═══════════════════════════════════════════════════ */
40+
41+
interface BarChartProps {
42+
title?: string;
43+
orientation?: "horizontal" | "vertical";
44+
animate?: boolean;
45+
showValues?: boolean;
46+
children: ReactNode;
47+
}
48+
49+
export function BarChart({
50+
title,
51+
orientation = "horizontal",
52+
animate = true,
53+
showValues = true,
54+
children,
55+
}: BarChartProps) {
56+
const ref = useRef<HTMLDivElement>(null);
57+
const inView = useInView(ref, { once: true, margin: "-30px" });
58+
const shouldAnimate = animate && inView;
59+
60+
/* ── Extract children ── */
61+
const bars: BarProps[] = [];
62+
const sliderDefs: SliderDef[] = [];
63+
64+
Children.forEach(children, (child) => {
65+
if (!child || typeof child !== "object" || !("props" in child)) return;
66+
const p = (child as AnyElement).props;
67+
if (typeof p.label === "string" && (p.value !== undefined || p.expr)) {
68+
bars.push(p as BarProps);
69+
} else if (typeof p.name === "string" && p.min !== undefined) {
70+
sliderDefs.push(p as SliderDef);
71+
}
72+
});
73+
74+
/* ── Slider state ── */
75+
const [vars, setVars] = useState<Record<string, number>>(() => {
76+
const v: Record<string, number> = {};
77+
for (const s of sliderDefs) v[s.name] = s.default ?? 0;
78+
return v;
79+
});
80+
81+
/* ── Compute bar values ── */
82+
const values = bars.map((b) => {
83+
if (b.expr) {
84+
const v = safeEval(b.expr, vars);
85+
return isFinite(v) ? Math.max(0, v) : 0;
86+
}
87+
return Math.max(0, b.value ?? 0);
88+
});
89+
90+
const maxVal = Math.max(...values, 1);
91+
92+
const PALETTE = [
93+
"#ff0000",
94+
"#0066cc",
95+
"#22863a",
96+
"#cc6600",
97+
"#8b5cf6",
98+
"#06b6d4",
99+
"#ec4899",
100+
"#f59e0b",
101+
];
102+
103+
const isH = orientation === "horizontal";
104+
105+
return (
106+
<div ref={ref} className="mb-6 border-2 border-[#1a1a1a]">
107+
{/* Header */}
108+
{title && (
109+
<div
110+
className="flex items-center px-4 py-2.5 border-b-2 border-[#1a1a1a]"
111+
style={{ backgroundColor: "#1a1a1a" }}
112+
>
113+
<span
114+
className="text-[10px] font-bold uppercase tracking-[0.15em]"
115+
style={{ fontFamily: "var(--font-mono)", color: "#fafafa" }}
116+
>
117+
{title}
118+
</span>
119+
</div>
120+
)}
121+
122+
{/* Bars */}
123+
<div
124+
className={`px-4 py-4 ${isH ? "space-y-3" : "flex items-end gap-3 justify-center"}`}
125+
style={{
126+
backgroundColor: "#fafafa",
127+
minHeight: isH ? undefined : 180,
128+
}}
129+
>
130+
{bars.map((b, i) => {
131+
const pct = maxVal > 0 ? (values[i] / maxVal) * 100 : 0;
132+
const c = b.color || PALETTE[i % PALETTE.length];
133+
134+
if (isH) {
135+
/* ── Horizontal bars ── */
136+
return (
137+
<div key={i} className="flex items-center gap-3">
138+
<span
139+
className="shrink-0 text-[9px] font-bold uppercase tracking-[0.1em] min-w-20 text-right"
140+
style={{
141+
fontFamily: "var(--font-mono)",
142+
color: "#1a1a1a",
143+
}}
144+
>
145+
{b.label}
146+
</span>
147+
<div className="flex-1 h-6 relative" style={{ backgroundColor: "#eee" }}>
148+
<motion.div
149+
className="h-full absolute left-0 top-0"
150+
style={{ backgroundColor: c }}
151+
initial={shouldAnimate ? { width: 0 } : false}
152+
animate={{ width: `${pct}%` }}
153+
transition={{
154+
delay: shouldAnimate ? i * 0.1 : 0,
155+
duration: 0.6,
156+
type: "spring",
157+
stiffness: 80,
158+
damping: 15,
159+
}}
160+
/>
161+
{showValues && (
162+
<span
163+
className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] font-bold"
164+
style={{
165+
fontFamily: "var(--font-mono)",
166+
color: pct > 60 ? "#fff" : "#1a1a1a",
167+
zIndex: 1,
168+
}}
169+
>
170+
{values[i] >= 100
171+
? values[i].toFixed(0)
172+
: values[i].toFixed(1)}
173+
</span>
174+
)}
175+
</div>
176+
</div>
177+
);
178+
}
179+
180+
/* ── Vertical bars ── */
181+
return (
182+
<div
183+
key={i}
184+
className="flex flex-col items-center gap-1"
185+
style={{ flex: "1 1 0" }}
186+
>
187+
{showValues && (
188+
<span
189+
className="text-[9px] font-bold"
190+
style={{
191+
fontFamily: "var(--font-mono)",
192+
color: c,
193+
}}
194+
>
195+
{values[i] >= 100
196+
? values[i].toFixed(0)
197+
: values[i].toFixed(1)}
198+
</span>
199+
)}
200+
<div
201+
className="w-full relative"
202+
style={{
203+
height: 120,
204+
backgroundColor: "#eee",
205+
}}
206+
>
207+
<motion.div
208+
className="w-full absolute bottom-0 left-0"
209+
style={{ backgroundColor: c }}
210+
initial={shouldAnimate ? { height: 0 } : false}
211+
animate={{ height: `${pct}%` }}
212+
transition={{
213+
delay: shouldAnimate ? i * 0.1 : 0,
214+
duration: 0.6,
215+
type: "spring",
216+
stiffness: 80,
217+
damping: 15,
218+
}}
219+
/>
220+
</div>
221+
<span
222+
className="text-[8px] font-bold uppercase tracking-[0.05em] text-center"
223+
style={{
224+
fontFamily: "var(--font-mono)",
225+
color: "#1a1a1a",
226+
}}
227+
>
228+
{b.label}
229+
</span>
230+
</div>
231+
);
232+
})}
233+
</div>
234+
235+
{/* Sliders (if interactive) */}
236+
{sliderDefs.length > 0 && (
237+
<div
238+
className="px-4 py-3 border-t-2 border-[#e5e5e5] space-y-3"
239+
style={{ backgroundColor: "#fff" }}
240+
>
241+
{sliderDefs.map((s) => {
242+
const val = vars[s.name] ?? s.default ?? 0;
243+
const step = s.step ?? 0.1;
244+
const decimals = step >= 1 ? 0 : step >= 0.1 ? 1 : 2;
245+
246+
return (
247+
<div key={s.name} className="flex items-center gap-3">
248+
<label
249+
className="shrink-0 text-[10px] font-bold uppercase tracking-[0.1em] min-w-24"
250+
style={{
251+
fontFamily: "var(--font-mono)",
252+
color: "#1a1a1a",
253+
}}
254+
>
255+
{s.label || s.name}
256+
</label>
257+
<input
258+
type="range"
259+
min={s.min ?? 0}
260+
max={s.max ?? 100}
261+
step={step}
262+
value={val}
263+
onChange={(e) =>
264+
setVars((p) => ({
265+
...p,
266+
[s.name]: parseFloat(e.target.value),
267+
}))
268+
}
269+
className="flex-1 h-1.5 cursor-pointer accent-[#ff0000]"
270+
/>
271+
<span
272+
className="shrink-0 text-[11px] font-bold tabular-nums w-14 text-right"
273+
style={{
274+
fontFamily: "var(--font-mono)",
275+
color: "#ff0000",
276+
}}
277+
>
278+
{val.toFixed(decimals)}
279+
</span>
280+
</div>
281+
);
282+
})}
283+
</div>
284+
)}
285+
</div>
286+
);
287+
}

0 commit comments

Comments
 (0)