Skip to content

Commit b537da9

Browse files
committed
app: Fractal Flora
1 parent 6ff841a commit b537da9

File tree

3 files changed

+348
-0
lines changed

3 files changed

+348
-0
lines changed

public/apps/apps.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,15 @@
288288
"icon": "MagicWandIcon",
289289
"order": 3,
290290
"apps": [
291+
{
292+
"slug": "fractal-flora",
293+
"to": "/apps/fractal-flora",
294+
"title": "Fractal Flora",
295+
"description": "Grow digital trees using recursive mathematics.",
296+
"icon": "PlantIcon",
297+
"pinned_order": 18,
298+
"created_at": "2025-11-29T15:00:00+03:00"
299+
},
291300
{
292301
"slug": "color-palette-generator",
293302
"to": "/apps/color-palette-generator",

src/components/AnimatedRoutes.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ const KeyboardTypingSpeedTesterPage = lazy(
125125
const NotepadPage = lazy(() => import('../pages/apps/NotepadPage'));
126126
const CozyAppPage = lazy(() => import('../pages/apps/CozyAppPage'));
127127
const SpirographPage = lazy(() => import('../pages/apps/SpirographPage'));
128+
const FractalFloraPage = lazy(() => import('../pages/apps/FractalFloraPage'));
128129
const FezynthPage = lazy(() => import('../pages/apps/FezynthPage'));
129130
const CodeSeancePage = lazy(() => import('../pages/apps/CodeSeancePage'));
130131
const PinnedAppPage = lazy(() => import('../pages/PinnedAppPage'));
@@ -801,6 +802,10 @@ function AnimatedRoutes() {
801802
path="/apps::spiro"
802803
element={<Navigate to="/apps/spirograph" replace />}
803804
/>
805+
<Route
806+
path="/apps::flora"
807+
element={<Navigate to="/apps/fractal-flora" replace />}
808+
/>
804809
{/* End of hardcoded redirects */}
805810
<Route
806811
path="/apps/ip"
@@ -1731,6 +1736,22 @@ function AnimatedRoutes() {
17311736
</motion.div>
17321737
}
17331738
/>
1739+
<Route
1740+
path="/apps/fractal-flora"
1741+
element={
1742+
<motion.div
1743+
initial="initial"
1744+
animate="in"
1745+
exit="out"
1746+
variants={pageVariants}
1747+
transition={pageTransition}
1748+
>
1749+
<Suspense fallback={<Loading />}>
1750+
<FractalFloraPage />
1751+
</Suspense>
1752+
</motion.div>
1753+
}
1754+
/>
17341755
{/*Pinned Apps*/}
17351756
<Route
17361757
path="/pinned-apps"

src/pages/apps/FractalFloraPage.js

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
import React, { useState, useEffect, useRef, useCallback } from 'react';
2+
import { Link } from 'react-router-dom';
3+
import {
4+
ArrowLeftIcon,
5+
PlantIcon,
6+
DownloadSimple,
7+
ArrowsClockwise,
8+
Play,
9+
} from '@phosphor-icons/react';
10+
import useSeo from '../../hooks/useSeo';
11+
import { useToast } from '../../hooks/useToast';
12+
import BreadcrumbTitle from '../../components/BreadcrumbTitle';
13+
14+
const FractalFloraPage = () => {
15+
useSeo({
16+
title: 'Fractal Flora | Fezcodex',
17+
description: 'Grow digital trees using recursive mathematics.',
18+
keywords: ['fractal', 'tree', 'recursive', 'generative art', 'math'],
19+
});
20+
21+
const { addToast } = useToast();
22+
const canvasRef = useRef(null);
23+
24+
// --- Parameters ---
25+
const [depth, setDepth] = useState(10);
26+
const [angle, setAngle] = useState(25);
27+
const [lengthBase, setLengthBase] = useState(120);
28+
const [lengthMultiplier, setLengthMultiplier] = useState(0.7);
29+
const [asymmetry, setAsymmetry] = useState(0); // -10 to 10 degrees bias
30+
const [randomness, setRandomness] = useState(0.2); // 0 to 1
31+
const [season, setSeason] = useState('summer'); // summer, autumn, winter, spring, neon
32+
33+
// --- Animation State ---
34+
const [isAnimating, setIsAnimating] = useState(false);
35+
// We use a "progress" value for animation (0 to 1)
36+
// But for a recursive tree, animating "growth" is tricky without a frame loop.
37+
// We'll use a simple frame loop to draw the tree progressively or just redraw fast.
38+
// For high-performance sliders, instant redraw is best.
39+
// We can add a "Grow" button that resets and animates.
40+
41+
const drawTree = useCallback(
42+
(ctx, w, h) => {
43+
ctx.clearRect(0, 0, w, h);
44+
45+
// Color Palettes
46+
const palettes = {
47+
summer: { trunk: '#5D4037', leaf: '#4CAF50', bg: '#E8F5E9' },
48+
autumn: { trunk: '#3E2723', leaf: '#FF5722', bg: '#FFF3E0' },
49+
winter: { trunk: '#212121', leaf: '#B0BEC5', bg: '#ECEFF1' },
50+
spring: { trunk: '#795548', leaf: '#F48FB1', bg: '#FCE4EC' },
51+
neon: { trunk: '#EA00FF', leaf: '#00E5FF', bg: '#120024' },
52+
};
53+
54+
const theme = palettes[season];
55+
56+
// Draw Background
57+
ctx.fillStyle = theme.bg;
58+
ctx.fillRect(0, 0, w, h);
59+
60+
const maxDepth = depth;
61+
62+
// Recursive Function
63+
const branch = (x, y, len, ang, d) => {
64+
ctx.beginPath();
65+
ctx.save();
66+
ctx.strokeStyle = d > 2 ? theme.trunk : theme.leaf;
67+
ctx.fillStyle = theme.leaf;
68+
ctx.lineWidth = d > 2 ? d : 1;
69+
ctx.lineCap = 'round';
70+
71+
ctx.translate(x, y);
72+
ctx.rotate((ang * Math.PI) / 180);
73+
ctx.moveTo(0, 0);
74+
ctx.lineTo(0, -len);
75+
ctx.stroke();
76+
77+
if (d > 0) {
78+
// Leaves at the end
79+
if (d <= 2) {
80+
ctx.beginPath();
81+
ctx.arc(0, -len, 4 * (randomness + 0.5), 0, Math.PI * 2);
82+
ctx.fill();
83+
}
84+
85+
// Calculate next branch props
86+
// Add randomness to length and angle
87+
const randLen = 1 + (Math.random() - 0.5) * randomness;
88+
const randAng = (Math.random() - 0.5) * randomness * 30;
89+
90+
const nextLen = len * lengthMultiplier * randLen;
91+
92+
// Right branch
93+
branch(0, -len, nextLen, angle + asymmetry + randAng, d - 1);
94+
// Left branch
95+
branch(0, -len, nextLen, -angle + asymmetry + randAng, d - 1);
96+
97+
// Optional middle branch for lushness if randomness is high
98+
if (randomness > 0.6 && d > 4) {
99+
branch(0, -len, nextLen * 0.8, randAng, d - 2);
100+
}
101+
}
102+
ctx.restore();
103+
};
104+
105+
// Start the tree at bottom center
106+
branch(w / 2, h, lengthBase, 0, maxDepth);
107+
},
108+
[depth, angle, lengthBase, lengthMultiplier, asymmetry, randomness, season],
109+
);
110+
111+
// Effect to redraw when params change
112+
useEffect(() => {
113+
const canvas = canvasRef.current;
114+
if (!canvas) return;
115+
const ctx = canvas.getContext('2d');
116+
const width = canvas.width;
117+
const height = canvas.height;
118+
119+
// Use a timeout to debounce slightly or just draw immediately
120+
// RequestAnimationFrame ensures we don't block UI
121+
let rafId = requestAnimationFrame(() => drawTree(ctx, width, height));
122+
123+
return () => cancelAnimationFrame(rafId);
124+
}, [drawTree]);
125+
126+
// Init Canvas Size
127+
useEffect(() => {
128+
const canvas = canvasRef.current;
129+
if (canvas) {
130+
// High res for sharpness
131+
canvas.width = 800;
132+
canvas.height = 600;
133+
}
134+
}, []);
135+
136+
const handleDownload = () => {
137+
const canvas = canvasRef.current;
138+
const link = document.createElement('a');
139+
link.download = `fractal_flora_${season}.png`;
140+
link.href = canvas.toDataURL();
141+
link.click();
142+
addToast({
143+
title: 'Saved',
144+
message: 'Tree saved to device.',
145+
duration: 3000,
146+
});
147+
};
148+
149+
const randomizeParams = () => {
150+
setAngle(15 + Math.random() * 40);
151+
setLengthMultiplier(0.6 + Math.random() * 0.2);
152+
setAsymmetry((Math.random() - 0.5) * 20);
153+
setRandomness(Math.random() * 0.8);
154+
const seasons = ['summer', 'autumn', 'winter', 'spring', 'neon'];
155+
setSeason(seasons[Math.floor(Math.random() * seasons.length)]);
156+
};
157+
158+
return (
159+
<div className="min-h-screen bg-gray-50 flex flex-col">
160+
<div className="container mx-auto px-4 py-8 flex-grow flex flex-col max-w-6xl">
161+
{/* Header */}
162+
<Link
163+
to="/apps"
164+
className="group text-primary-400 hover:underline flex items-center justify-center gap-2 text-lg mb-4"
165+
>
166+
<ArrowLeftIcon className="text-xl transition-transform group-hover:-translate-x-1" />{' '}
167+
Back to Apps
168+
</Link>
169+
<BreadcrumbTitle title="Fractal Flora" slug="flora" />
170+
171+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 flex-grow">
172+
{/* Controls */}
173+
<div className="space-y-6 bg-white p-6 rounded-xl shadow-md border border-gray-200 h-fit">
174+
<div className="flex gap-2 mb-4">
175+
<button
176+
onClick={randomizeParams}
177+
className="flex-1 py-2 px-4 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center justify-center gap-2 transition-colors"
178+
>
179+
<ArrowsClockwise size={20} /> Randomize
180+
</button>
181+
<button
182+
onClick={handleDownload}
183+
className="px-4 py-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200"
184+
title="Download"
185+
>
186+
<DownloadSimple size={24} />
187+
</button>
188+
</div>
189+
190+
{/* Season Selector */}
191+
<div className="flex gap-2 justify-between p-1 bg-gray-100 rounded-lg">
192+
{['spring', 'summer', 'autumn', 'winter', 'neon'].map((s) => (
193+
<button
194+
key={s}
195+
onClick={() => setSeason(s)}
196+
className={`flex-1 py-1 rounded-md text-xs font-bold uppercase transition-all ${season === s ? 'bg-white shadow-sm text-gray-900' : 'text-gray-400 hover:text-gray-600'}`}
197+
>
198+
{s}
199+
</button>
200+
))}
201+
</div>
202+
203+
<div className="space-y-4">
204+
<div>
205+
<label className="flex justify-between text-sm font-medium text-gray-700 mb-1">
206+
<span>Recursion Depth</span>
207+
<span className="text-gray-500">{depth}</span>
208+
</label>
209+
<input
210+
type="range"
211+
min="1"
212+
max="14"
213+
value={depth}
214+
onChange={(e) => setDepth(Number(e.target.value))}
215+
className="w-full accent-green-600"
216+
/>
217+
</div>
218+
<div>
219+
<label className="flex justify-between text-sm font-medium text-gray-700 mb-1">
220+
<span>Branch Angle</span>
221+
<span className="text-gray-500">{Math.round(angle)}°</span>
222+
</label>
223+
<input
224+
type="range"
225+
min="0"
226+
max="120"
227+
value={angle}
228+
onChange={(e) => setAngle(Number(e.target.value))}
229+
className="w-full accent-green-600"
230+
/>
231+
</div>
232+
<div>
233+
<label className="flex justify-between text-sm font-medium text-gray-700 mb-1">
234+
<span>Length Multiplier</span>
235+
<span className="text-gray-500">
236+
{lengthMultiplier.toFixed(2)}
237+
</span>
238+
</label>
239+
<input
240+
type="range"
241+
min="0.5"
242+
max="0.85"
243+
step="0.01"
244+
value={lengthMultiplier}
245+
onChange={(e) => setLengthMultiplier(Number(e.target.value))}
246+
className="w-full accent-green-600"
247+
/>
248+
</div>
249+
<div>
250+
<label className="flex justify-between text-sm font-medium text-gray-700 mb-1">
251+
<span>Trunk Base Size</span>
252+
<span className="text-gray-500">{lengthBase}px</span>
253+
</label>
254+
<input
255+
type="range"
256+
min="50"
257+
max="200"
258+
value={lengthBase}
259+
onChange={(e) => setLengthBase(Number(e.target.value))}
260+
className="w-full accent-green-600"
261+
/>
262+
</div>
263+
<div>
264+
<label className="flex justify-between text-sm font-medium text-gray-700 mb-1">
265+
<span>Wind / Asymmetry</span>
266+
<span className="text-gray-500">
267+
{Math.round(asymmetry)}°
268+
</span>
269+
</label>
270+
<input
271+
type="range"
272+
min="-20"
273+
max="20"
274+
value={asymmetry}
275+
onChange={(e) => setAsymmetry(Number(e.target.value))}
276+
className="w-full accent-blue-500"
277+
/>
278+
</div>
279+
<div>
280+
<label className="flex justify-between text-sm font-medium text-gray-700 mb-1">
281+
<span>Organic Randomness</span>
282+
<span className="text-gray-500">
283+
{Math.round(randomness * 100)}%
284+
</span>
285+
</label>
286+
<input
287+
type="range"
288+
min="0"
289+
max="1"
290+
step="0.01"
291+
value={randomness}
292+
onChange={(e) => setRandomness(Number(e.target.value))}
293+
className="w-full accent-orange-500"
294+
/>
295+
</div>
296+
</div>
297+
298+
<div className="bg-green-50 p-3 rounded-lg text-xs text-green-800">
299+
<strong>Fact:</strong> Nature uses fractals to maximize surface
300+
area for sunlight (leaves) and nutrients (roots) efficiently!
301+
</div>
302+
</div>
303+
304+
{/* Canvas */}
305+
<div className="lg:col-span-2 bg-white rounded-xl shadow-lg border border-gray-200 p-4 flex items-center justify-center overflow-hidden">
306+
<canvas
307+
ref={canvasRef}
308+
className="max-w-full h-auto rounded-lg shadow-inner border border-gray-100"
309+
style={{ maxHeight: '600px' }}
310+
/>
311+
</div>
312+
</div>
313+
</div>
314+
</div>
315+
);
316+
};
317+
318+
export default FractalFloraPage;

0 commit comments

Comments
 (0)