drop: bonsai

This commit is contained in:
nicwands 2026-08-03 14:27:31 -04:00
parent bbe9d7e889
commit d243f4caea
81 changed files with 886 additions and 61 deletions

7
.zed/settings.json Normal file
View file

@ -0,0 +1,7 @@
{
"formatter": "prettier",
"format_on_save": "on",
"prettier": {
"allowed": true
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/drops/bonsai/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bonsai</title>
<script type="module" crossorigin src="/drops/bonsai/assets/index-kYUuDunm.js"></script>
<link rel="stylesheet" crossorigin href="/drops/bonsai/assets/index-B4Fea1ir.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View file

@ -0,0 +1,131 @@
{
"name": "bonsai",
"title": "Bonsai Rings",
"date": "2026-07-24",
"tags": [
"tag1",
"tag2",
"tag3"
],
"description": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "Generates a canvas drawing representing the growth rings of a tree, configurable with parameters."
}
]
},
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "Rings: "
},
{
"type": "text",
"text": "How many years of growth the tree has. More rings produce an older, denser cross-section."
}
]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "Noise:"
},
{
"type": "text",
"text": " How bumpy or irregular each ring's outline is. Low values produce smooth, gently curved rings; high values create more jagged, uneven edges."
}
]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "Variation:"
},
{
"type": "text",
"text": " How much each new ring's shape drifts away from the one before it. Near zero, all rings share the same basic silhouette; higher values let the tree's shape wander more freely as it ages."
}
]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "Mutation:"
},
{
"type": "text",
"text": " How unevenly the tree grows around its circumference within a single year. Low values produce fairly uniform, concentric rings; high values create dramatic thick-and-thin sections within each ring."
}
]
}
]
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "The seed for the generated rings is stored in the user's browser, so their tree will look the same across reloads."
}
]
}
]
},
"published": true
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/drops/endless-journal/favicon.png" /> <link rel="icon" type="image/svg+xml" href="/drops/endless-journal/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Endless Journal</title> <title>Endless Journal</title>
<script type="module" crossorigin src="/drops/endless-journal/assets/index-9EwT6NAf.js"></script> <script type="module" crossorigin src="/drops/endless-journal/assets/index-CB8ifUrE.js"></script>
<link rel="stylesheet" crossorigin href="/drops/endless-journal/assets/index-PMwlnwUr.css"> <link rel="stylesheet" crossorigin href="/drops/endless-journal/assets/index-iXXiVEt8.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/drops/flowmap/favicon.png" /> <link rel="icon" type="image/svg+xml" href="/drops/flowmap/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flowmap</title> <title>Flowmap</title>
<script type="module" crossorigin src="/drops/flowmap/assets/index-BgOfTPdu.js"></script> <script type="module" crossorigin src="/drops/flowmap/assets/index-B9AhPugJ.js"></script>
<link rel="stylesheet" crossorigin href="/drops/flowmap/assets/index-Ct_TWNmM.css"> <link rel="stylesheet" crossorigin href="/drops/flowmap/assets/index-DdbOMs3_.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/drops/image-melody/favicon.png" /> <link rel="icon" type="image/svg+xml" href="/drops/image-melody/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Image Melody</title> <title>Image Melody</title>
<script type="module" crossorigin src="/drops/image-melody/assets/index-DOVFg_Mq.js"></script> <script type="module" crossorigin src="/drops/image-melody/assets/index-C-dHDnAG.js"></script>
<link rel="stylesheet" crossorigin href="/drops/image-melody/assets/index-CmoBfZze.css"> <link rel="stylesheet" crossorigin href="/drops/image-melody/assets/index-Dk6TxSdE.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/drops/noise-shader/favicon.png" /> <link rel="icon" type="image/svg+xml" href="/drops/noise-shader/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Noise Shader</title> <title>Noise Shader</title>
<script type="module" crossorigin src="/drops/noise-shader/assets/index-CVMeXlNb.js"></script> <script type="module" crossorigin src="/drops/noise-shader/assets/index-CIpH8ME8.js"></script>
<link rel="stylesheet" crossorigin href="/drops/noise-shader/assets/index-B21avM17.css"> <link rel="stylesheet" crossorigin href="/drops/noise-shader/assets/index-LBxPoCAA.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -1,4 +1,5 @@
[ [
"bonsai",
"flowmap", "flowmap",
"image-melody", "image-melody",
"noise-shader", "noise-shader",

View file

@ -18,8 +18,8 @@ onMounted(async () => {
manifest.value = await fetch('/manifest.json').then((r) => r.json()) manifest.value = await fetch('/manifest.json').then((r) => r.json())
await loadFonts([ await loadFonts([
{ {
name: 'Instrument Serif', name: 'Galapagos A',
styles: ['italic'], styles: ['normal'],
weights: [400] weights: [400]
}, },
{ {

View file

@ -33,15 +33,15 @@ const props = defineProps({
}, },
fontSize: { fontSize: {
type: Number, type: Number,
default: 0.2 // Scale factor relative to screen width default: 0.2
}, },
letterSpacing: { letterSpacing: {
type: Number, type: Number,
default: 0 // Additional spacing in em units (can be negative) default: 0
}, },
blobStrength: { blobStrength: {
type: Number, type: Number,
default: 1.0 // Controls metaball merge distance (higher = merges from farther away) default: 1.0
} }
}) })
@ -238,6 +238,7 @@ const materialProps = {
u_distance_range: { value: 2.0 }, // From atlas distanceRange u_distance_range: { value: 2.0 }, // From atlas distanceRange
u_is_text: { value: 1.0 }, // Flag to indicate this is text u_is_text: { value: 1.0 }, // Flag to indicate this is text
u_blob_strength: { value: props.blobStrength }, u_blob_strength: { value: props.blobStrength },
u_time: { value: 0.0 },
}, },
} }
@ -255,16 +256,18 @@ const bgMaterialProps = {
u_distance_range: { value: 2.0 }, u_distance_range: { value: 2.0 },
u_is_text: { value: 0.0 }, // Background only shows metaball u_is_text: { value: 0.0 }, // Background only shows metaball
u_blob_strength: { value: props.blobStrength }, u_blob_strength: { value: props.blobStrength },
u_time: { value: 0.0 },
}, },
} }
const { onRender } = useLoop() const { onRender } = useLoop()
onRender(() => { onRender(({ elapsed }) => {
const resolution = [width.value, height.value] const resolution = [width.value, height.value]
if (textMesh.value?.material) { if (textMesh.value?.material) {
textMesh.value.material.uniforms.u_resolution.value = resolution textMesh.value.material.uniforms.u_resolution.value = resolution
textMesh.value.material.uniforms.u_blob_strength.value = props.blobStrength textMesh.value.material.uniforms.u_blob_strength.value = props.blobStrength
textMesh.value.material.uniforms.u_time.value = elapsed
// Update atlas texture reference if it changed // Update atlas texture reference if it changed
if (atlasTexture.value && textMesh.value.material.uniforms.u_msdf.value !== atlasTexture.value) { if (atlasTexture.value && textMesh.value.material.uniforms.u_msdf.value !== atlasTexture.value) {
@ -275,6 +278,7 @@ onRender(() => {
if (bgPlane.value?.material) { if (bgPlane.value?.material) {
bgPlane.value.material.uniforms.u_resolution.value = resolution bgPlane.value.material.uniforms.u_resolution.value = resolution
bgPlane.value.material.uniforms.u_blob_strength.value = props.blobStrength bgPlane.value.material.uniforms.u_blob_strength.value = props.blobStrength
bgPlane.value.material.uniforms.u_time.value = elapsed
} }
}) })
</script> </script>

View file

@ -11,12 +11,50 @@ uniform float u_text_scale;
uniform float u_distance_range; uniform float u_distance_range;
uniform float u_is_text; uniform float u_is_text;
uniform float u_blob_strength; uniform float u_blob_strength;
uniform float u_time;
// MSDF median function - extracts signed distance from multi-channel texture // MSDF median function - extracts signed distance from multi-channel texture
float median(float r, float g, float b) { float median(float r, float g, float b) {
return max(min(r, g), min(max(r, g), b)); return max(min(r, g), min(max(r, g), b));
} }
// Hash for noise
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
// Smooth value noise
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
u.y
);
}
float sdUnevenCapsule( vec2 p, float r1, float r2, float h )
{
p.x = abs(p.x);
float b = (r1-r2)/h;
float a = sqrt(1.0-b*b);
float k = dot(p,vec2(-b,a));
if( k < 0.0 ) return length(p) - r1;
if( k > a*h ) return length(p-vec2(0.0,h)) - r2;
return dot(p, vec2(a,b) ) - r1;
}
// Layered noise for more organic blob edge
float fbm(vec2 p) {
float v = 0.0;
v += 0.500 * noise(p * 1.0);
v += 0.250 * noise(p * 2.0);
v += 0.125 * noise(p * 4.0);
return v;
}
void main() { void main() {
float aspect = u_resolution.x / u_resolution.y; float aspect = u_resolution.x / u_resolution.y;
@ -28,30 +66,74 @@ void main() {
float msdf = median(msdfSample.r, msdfSample.g, msdfSample.b); float msdf = median(msdfSample.r, msdfSample.g, msdfSample.b);
// Proper MSDF distance field scaling for high quality rendering // Proper MSDF distance field scaling for high quality rendering
// Calculate screen-space derivatives to determine proper threshold
vec2 unitRange = u_distance_range / u_atlas_size; vec2 unitRange = u_distance_range / u_atlas_size;
vec2 screenTexSize = vec2(1.0) / fwidth(v_uv); vec2 screenTexSize = vec2(1.0) / fwidth(v_uv);
float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0); float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0);
float screenPxDistance = screenPxRange * (msdf - 0.5); float screenPxDistance = screenPxRange * (msdf - 0.5);
// Convert MSDF distance to a field value centered at 0.5
// This creates a smooth falloff around the glyph edge
tf = screenPxDistance / screenPxRange + 0.5; tf = screenPxDistance / screenPxRange + 0.5;
} }
// Mouse blob: smooth circular falloff in screen space, aspect-corrected // Blob: uneven capsule SDF centered on the mouse, oriented vertically
float blobRadius = 0.05; // r1 = bottom radius (larger), r2 = top radius (smaller), h = height
vec2 d = (v_screen_uv - u_mouse) * vec2(aspect, 1.0); float r1 = 0.02;
float bf = 1.0 - smoothstep(0.0, blobRadius, length(d)); float r2 = 0.01;
float h = 0.03;
// Metaball field sum: scale blob contribution to control merge distance // Aspect-corrected offset from mouse; shift p so capsule is centred on mouse
// Higher u_blob_strength = blob merges from farther away vec2 d = (v_screen_uv - u_mouse) * vec2(aspect, 1.0);
// Lower u_blob_strength = blob must get closer to merge vec2 capsuleP = d + vec2(0.0, h * 0.5); // sdUnevenCapsule base sits at origin
float sdf = sdUnevenCapsule(capsuleP, r1, r2, h);
// Add FBM noise directly to the SDF value to organically warp the boundary
vec2 noiseCoord = v_screen_uv * 5.0 + u_time * 0.25;
float edgeNoise = (fbm(noiseCoord) - 0.5) * 0.03; // centred, ±1.5% of screen
float noisySdf = sdf + edgeNoise;
// Convert SDF (neg=inside) to a 0→1 field matching the metaball convention
float softness = 0.008;
float bf = smoothstep(softness, -softness, noisySdf);
// Metaball field sum
float field = tf + (bf * u_blob_strength); float field = tf + (bf * u_blob_strength);
// Anti-aliased edge at the 0.5 isosurface // ── 3-D depth via screen-space normals ──────────────────────────────────
// The field gradient points toward the interior of each shape element.
// Treating the field as a height map gives a convex-bulge normal.
float fx = dFdx(field);
float fy = dFdy(field);
// z controls how "round" the surface appears; larger = flatter, smaller = steeper sides
vec3 normal = normalize(vec3(-fx, -fy, 0.1));
// Key light: upper-left, slightly toward viewer
vec3 lightDir = normalize(vec3(-0.4, 0.7, 1.0));
float diffuse = max(dot(normal, lightDir), 0.0);
// Rim / fill light from lower-right for depth separation
vec3 rimDir = normalize(vec3(0.6, -0.4, 0.5));
float rim = pow(max(dot(normal, rimDir), 0.0), 3.0) * 0.25;
// Specular (Blinn-Phong)
vec3 viewDir = vec3(0.0, 0.0, 1.0);
vec3 halfDir = normalize(lightDir + viewDir);
float specular = pow(max(dot(normal, halfDir), 0.0), 48.0);
// Base color derived from the surface normal's xy — shifts hue across the surface
// as the shape curves, giving an iridescent/sculpted feel
vec3 baseColor = vec3(
0.3 + normal.x * 0.6, // red channel: shifts with left/right curvature
0.2 + normal.y * 0.5, // green channel: shifts with up/down curvature
0.7 - normal.x * 0.3 // blue channel: inversely tracks red for contrast
);
// Apply lighting on top: darken with diffuse/rim, blow out to white with specular
vec3 color = baseColor * (0.1 + 0.7 * diffuse + rim) + vec3(0.7 * specular);
// Anti-aliased coverage mask at the 0.5 isosurface
float fw = fwidth(field); float fw = fwidth(field);
float alpha = smoothstep(0.5 - fw, 0.5 + fw, field); float alpha = smoothstep(0.5 - fw, 0.5 + fw, field);
gl_FragColor = vec4(0.0, 0.0, 0.0, alpha); gl_FragColor = vec4(color, alpha);
} }

View file

@ -4,7 +4,7 @@
<splash-title <splash-title
v-if="ready" v-if="ready"
text="Braindrops" text="Braindrops"
fontFamily="instrument-serif" fontFamily="iosevka-charon"
:letterSpacing="-0.1" :letterSpacing="-0.1"
:blobStrength="1" :blobStrength="1"
/> />

3
drops/bonsai/concept.md Normal file
View file

@ -0,0 +1,3 @@
- Growth rings
- seasonal change (lose leaves)
- ascii trees

13
drops/bonsai/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bonsai</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

30
drops/bonsai/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "bonsai",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"prettier": {
"tabWidth": 4,
"semi": false,
"singleQuote": true
},
"dependencies": {
"@vueuse/core": "^14.4.0",
"alea": "^1.0.1",
"intrinsic-scale": "^5.0.0",
"sass": "^1.98.0",
"sass-embedded": "^1.98.0",
"simplex-noise": "^4.0.3",
"uuid": "^14.0.1",
"vue": "^3.5.30"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,131 @@
{
"name": "bonsai",
"title": "Bonsai Rings",
"date": "2026-07-24",
"tags": [
"tag1",
"tag2",
"tag3"
],
"description": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "Generates a canvas drawing representing the growth rings of a tree, configurable with parameters."
}
]
},
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "Rings: "
},
{
"type": "text",
"text": "How many years of growth the tree has. More rings produce an older, denser cross-section."
}
]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "Noise:"
},
{
"type": "text",
"text": " How bumpy or irregular each ring's outline is. Low values produce smooth, gently curved rings; high values create more jagged, uneven edges."
}
]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "Variation:"
},
{
"type": "text",
"text": " How much each new ring's shape drifts away from the one before it. Near zero, all rings share the same basic silhouette; higher values let the tree's shape wander more freely as it ages."
}
]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "Mutation:"
},
{
"type": "text",
"text": " How unevenly the tree grows around its circumference within a single year. Low values produce fairly uniform, concentric rings; high values create dramatic thick-and-thin sections within each ring."
}
]
}
]
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "The seed for the generated rings is stored in the user's browser, so their tree will look the same across reloads."
}
]
}
]
},
"published": true
}

17
drops/bonsai/src/App.vue Normal file
View file

@ -0,0 +1,17 @@
<template>
<main class="drop theme-light">
<Bonsai />
</main>
</template>
<script setup>
import Bonsai from './components/Bonsai.vue'
</script>
<style lang="scss">
main.drop {
background: var(--theme-bg);
color: var(--theme-fg);
height: 100vh;
}
</style>

View file

@ -0,0 +1,213 @@
<template>
<div class="bonsai">
<canvas ref="canvas" />
<div class="controls">
<div class="control">
<range
v-model="numRings"
label="Rings"
:min="2"
:max="80"
:step="1"
/>
</div>
<div class="control">
<range
v-model="noiseSpatialFreq"
label="Noise"
:min="0.1"
:max="2"
/>
</div>
<div class="control">
<range
v-model="noiseRingStep"
label="Variation"
:min="0"
:max="1.5"
:step="0.05"
/>
</div>
<div class="control">
<range
v-model="mutationStrength"
label="Mutation"
:step="0.05"
/>
</div>
<btn @click="changeSeed">New Seed</btn>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watchEffect } from 'vue'
import { useElementSize, useStorage } from '@vueuse/core'
import { createNoise2D } from 'simplex-noise'
import { v4 as uuid } from 'uuid'
import Range from '@shared/Range.vue'
import Btn from '@shared/Btn.vue'
import alea from 'alea'
const canvas = ref()
const { width, height } = useElementSize(canvas)
// Seed — recreates the noise function whenever it changes
const seed = useStorage('bonsai-seed', '')
const changeSeed = () => {
seed.value = uuid()
}
if (!seed.value) changeSeed()
// Noise function is a computed so it rebuilds when the seed changes
const noise = computed(() => createNoise2D(alea(seed.value)))
// Persistent parameters
const numRings = ref(30)
const noiseSpatialFreq = ref(1)
const noiseRingStep = ref(0.35)
const mutationStrength = ref(0.4)
const TWO_PI = Math.PI * 2
const RESOLUTION = 360
const SECTION_ANGLE = TWO_PI / RESOLUTION
const generateRings = (maxRadius, noiseFunc, config) => {
const { numR, spatialFreq, ringStep, mutation } = config
const innerRadius = maxRadius * 0.04
// One growth amount per gap between consecutive rings
const numGaps = numR - 1
const rawGrowths = Array.from(
{ length: numGaps },
(_, g) => 0.15 + ((noiseFunc(g * 0.22, 1.5) + 1) / 2) * 1.7,
)
const totalRaw = rawGrowths.reduce((s, v) => s + v, 0)
const growthAmounts = rawGrowths.map(
(g) => (g / totalRaw) * (maxRadius - innerRadius),
)
const rings = []
// Innermost ring: small noisy circle
rings.push(
Array.from({ length: RESOLUTION + 1 }, (_, i) => {
const a = i * SECTION_ANGLE
const n = noiseFunc(
Math.cos(a) * spatialFreq,
Math.sin(a) * spatialFreq,
)
return innerRadius * (1 + n * 0.12)
}),
)
// Each subsequent ring derives its shape from the previous ring
for (let r = 1; r < numR; r++) {
const prev = rings[r - 1]
const base = growthAmounts[r - 1]
rings.push(
Array.from({ length: RESOLUTION + 1 }, (_, i) => {
const a = i * SECTION_ANGLE
const xN = Math.cos(a)
const yN = Math.sin(a)
const n = noiseFunc(
xN * spatialFreq + r * ringStep,
yN * spatialFreq + r * ringStep,
)
return prev[i] + base + n * base * mutation
}),
)
}
return { rings, growthAmounts }
}
const drawRing = (ctx, radii) => {
ctx.beginPath()
for (let i = 0; i <= RESOLUTION; i++) {
const a = i * SECTION_ANGLE
const x = Math.cos(a) * radii[i]
const y = Math.sin(a) * radii[i]
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
}
ctx.closePath()
ctx.stroke()
}
const draw = () => {
if (!canvas.value || !width.value || !height.value) return
const ctx = canvas.value.getContext('2d')
const w = width.value
const h = height.value
// Reassigning width resets and clears the canvas (including any prior transforms)
canvas.value.width = w
canvas.value.height = h
const maxRadius = Math.min(w, h) * 0.46
ctx.translate(w / 2, h / 2)
const { rings, growthAmounts } = generateRings(maxRadius, noise.value, {
numR: numRings.value,
spatialFreq: noiseSpatialFreq.value,
ringStep: noiseRingStep.value,
mutation: mutationStrength.value,
})
const minGrowth = Math.min(...growthAmounts)
const maxGrowth = Math.max(...growthAmounts)
const growthRange = maxGrowth - minGrowth || 1
rings.forEach((ring, idx) => {
const g = growthAmounts[Math.max(0, idx - 1)]
const t = (g - minGrowth) / growthRange // 0 = slow/dry, 1 = fast/wet
ctx.strokeStyle = '#000'
ctx.lineWidth = 0.8 + t * 1.4
drawRing(ctx, ring)
})
}
// watchEffect inside onMounted so canvas is available; automatically re-runs
// whenever any reactive value accessed inside draw() changes
onMounted(() => {
watchEffect(draw)
})
</script>
<style lang="scss">
.bonsai {
height: 100%;
position: relative;
canvas {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
.controls {
position: absolute;
bottom: var(--layout-margin);
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: flex-end;
gap: rs(16px);
white-space: nowrap;
.control {
display: flex;
flex-direction: column;
gap: rs(6px);
min-width: rs(150px);
}
}
}
</style>

View file

@ -0,0 +1,20 @@
export function clamp(min, input, max) {
return Math.max(min, Math.min(input, max));
}
export function mapRange(in_min, in_max, input, out_min, out_max) {
return ((input - in_min) * (out_max - out_min)) / (in_max - in_min) + out_min;
}
export function lerp(start, end, amt) {
return (1 - amt) * start + amt * end;
}
export function truncate(value, decimals) {
return parseFloat(value.toFixed(decimals));
}
export function wrapValue(value, lowBound, highBound) {
const range = highBound - lowBound;
return ((((value - lowBound) % range) + range) % range) + lowBound;
}

6
drops/bonsai/src/main.js Normal file
View file

@ -0,0 +1,6 @@
import '@shared/styles/shared.scss'
import './styles/main.scss'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

View file

@ -0,0 +1 @@
/* Drop specific styles */

View file

@ -0,0 +1,21 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, mergeConfig } from 'vite'
import sharedConfig from '../../shared/vite.config.js'
export default defineConfig(
mergeConfig(sharedConfig, {
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@shared': fileURLToPath(
new URL('../../shared', import.meta.url),
),
},
},
base: '/drops/bonsai/',
build: {
outDir: '../../app/public/drops/bonsai',
emptyOutDir: true,
},
}),
)

83
package-lock.json generated
View file

@ -13,7 +13,10 @@
"drops/*", "drops/*",
"cms", "cms",
"backend" "backend"
] ],
"devDependencies": {
"prettier": "^3.9.6"
}
}, },
"app": { "app": {
"name": "braindrops-app", "name": "braindrops-app",
@ -62,11 +65,16 @@
"vite": "^8.0.0" "vite": "^8.0.0"
} }
}, },
"drops/backend-bitch": { "drops/bonsai": {
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@vueuse/core": "^14.4.0",
"alea": "^1.0.1",
"intrinsic-scale": "^5.0.0",
"sass": "^1.98.0", "sass": "^1.98.0",
"sass-embedded": "^1.98.0", "sass-embedded": "^1.98.0",
"simplex-noise": "^4.0.3",
"uuid": "^14.0.1",
"vue": "^3.5.30" "vue": "^3.5.30"
}, },
"devDependencies": { "devDependencies": {
@ -1684,14 +1692,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vueuse/core": { "node_modules/@vueuse/core": {
"version": "14.3.0", "version": "14.4.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.4.0.tgz",
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", "integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/web-bluetooth": "^0.0.21", "@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.3.0", "@vueuse/metadata": "14.4.0",
"@vueuse/shared": "14.3.0" "@vueuse/shared": "14.4.0"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
@ -1701,18 +1709,18 @@
} }
}, },
"node_modules/@vueuse/metadata": { "node_modules/@vueuse/metadata": {
"version": "14.3.0", "version": "14.4.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.4.0.tgz",
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", "integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/@vueuse/shared": { "node_modules/@vueuse/shared": {
"version": "14.3.0", "version": "14.4.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.4.0.tgz",
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", "integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
@ -1733,6 +1741,12 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/alea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/alea/-/alea-1.0.1.tgz",
"integrity": "sha512-QU+wv+ziDXaMxRdsQg/aH7sVfWdhKps5YP97IIwFkHCsbDZA3k87JXoZ5/iuemf4ntytzIWeScrRpae8+lDrXA==",
"license": "MIT"
},
"node_modules/ast-kit": { "node_modules/ast-kit": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
@ -1779,10 +1793,6 @@
"node": ">=18.2.0" "node": ">=18.2.0"
} }
}, },
"node_modules/backend-bitch": {
"resolved": "drops/backend-bitch",
"link": true
},
"node_modules/birpc": { "node_modules/birpc": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
@ -1792,6 +1802,10 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/bonsai": {
"resolved": "drops/bonsai",
"link": true
},
"node_modules/braindrops-app": { "node_modules/braindrops-app": {
"resolved": "app", "resolved": "app",
"link": true "link": true
@ -2582,6 +2596,22 @@
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/prettier": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prosemirror-changeset": { "node_modules/prosemirror-changeset": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
@ -3212,6 +3242,12 @@
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/simplex-noise": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/simplex-noise/-/simplex-noise-4.0.3.tgz",
"integrity": "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==",
"license": "MIT"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -3473,6 +3509,19 @@
"integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==", "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/varint": { "node_modules/varint": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",

View file

@ -17,5 +17,8 @@
"dev": "npm run build:drops && npm run dev -w app", "dev": "npm run build:drops && npm run dev -w app",
"dev:backend": "npm run dev -w backend", "dev:backend": "npm run dev -w backend",
"build": "npm run build:drops && npm run build -w app" "build": "npm run build:drops && npm run build -w app"
},
"devDependencies": {
"prettier": "^3.9.6"
} }
} }

77
shared/Range.vue Normal file
View file

@ -0,0 +1,77 @@
<template>
<div class="shared-range">
<label>
<span>{{ label }}</span>
<span>{{ model }}</span>
</label>
<input
type="range"
v-model.number="model"
:min="min"
:max="max"
:step="step"
/>
</div>
</template>
<script setup>
const props = defineProps({
label: String,
min: { type: Number, default: 0 },
max: { type: Number, default: 1 },
step: { type: Number, default: 0.1 },
})
const model = defineModel()
</script>
<style lang="scss">
.shared-range {
label {
@include label;
display: flex;
justify-content: space-between;
gap: rs(8px);
opacity: 0.45;
}
input[type='range'] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: rs(2px);
background: var(--grey-400);
border-radius: rs(2px);
cursor: pointer;
outline: none;
&::-webkit-slider-thumb {
-webkit-appearance: none;
width: rs(12px);
height: rs(12px);
border-radius: 50%;
background: var(--theme-fg);
cursor: pointer;
transition: transform 150ms var(--ease-out-quad);
}
&::-moz-range-thumb {
width: rs(12px);
height: rs(12px);
border-radius: 50%;
background: var(--theme-fg);
border: none;
cursor: pointer;
transition: transform 150ms var(--ease-out-quad);
}
&:hover {
&::-webkit-slider-thumb {
transform: scale(1.3);
}
&::-moz-range-thumb {
transform: scale(1.3);
}
}
}
}
</style>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,4 +1,4 @@
@use "sass:color"; @use 'sass:color';
:root { :root {
@each $name, $color in getColors() { @each $name, $color in getColors() {

View file

@ -11,14 +11,14 @@
900 - Black (Heavy) 900 - Black (Heavy)
*/ */
/* Instrument Serif */ /* Galapagos A */
@font-face { @font-face {
font-family: 'Instrument Serif'; font-family: 'Galapagos A';
font-style: italic; font-style: normal;
font-weight: 400; font-weight: 400;
src: src:
url('@shared/fonts/instrument-serif-italic.woff2') format('woff2'), url('@shared/fonts/abc-galapagos-a-trial.woff2') format('woff2'),
url('@shared/fonts/instrument-serif-italic.woff') format('woff'); url('@shared/fonts/abc-galapagos-a-trial.woff') format('woff');
} }
/* Hanken Grotesk */ /* Hanken Grotesk */