initial-commit

This commit is contained in:
nicwands 2026-07-17 11:24:36 -04:00
commit f24db846cf
126 changed files with 11179 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
24

5
README.md Normal file
View file

@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

13
app/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>braindrops</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

25
app/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "braindrops-app",
"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": {
"sass": "^1.98.0",
"sass-embedded": "^1.98.0",
"vue": "^3.5.30"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.0"
}
}

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: 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/flowmap/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flowmap</title>
<script type="module" crossorigin src="/drops/flowmap/assets/index-BgOfTPdu.js"></script>
<link rel="stylesheet" crossorigin href="/drops/flowmap/assets/index-Ct_TWNmM.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View file

@ -0,0 +1,9 @@
{
"name": "flowmap",
"title": "Flowmap Test",
"description": "An interactive flowmap visualization experiment and more",
"date": "2026-07-16",
"tags": [
"visualization"
]
}

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: 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/testing/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Testing</title>
<script type="module" crossorigin src="/drops/testing/assets/index-BxlameFm.js"></script>
<link rel="stylesheet" crossorigin href="/drops/testing/assets/index-DAZLouSi.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View file

@ -0,0 +1,14 @@
{
"name": "testing",
"title": "Testing",
"description": "A brief description of what this drop does",
"date": "2026-07-16",
"tags": ["tag1", "tag2", "tag3"],
"links": [
{
"label": "Source Code",
"url": "https://github.com/username/repo"
}
],
"notes": "Any additional notes or thoughts about this drop"
}

BIN
app/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

4
app/public/manifest.json Normal file
View file

@ -0,0 +1,4 @@
[
"flowmap",
"testing"
]

102
app/src/App.vue Normal file
View file

@ -0,0 +1,102 @@
<template>
<main class="container">
<div v-if="activeDrop" class="drops theme-dark layout-grid">
<panel
:drop="activeDrop"
:index="activeIndex"
:previousActive="activeIndex > 0"
:nextActive="activeIndex < drops.length - 1"
@previous="onPrevious"
@next="onNext"
/>
<div class="drop">
<iframe :src="activeDrop.src" />
</div>
</div>
<div v-else class="splash theme-light">
<h1>Braindrops</h1>
<btn @click="activeIndex = 0">View Drops</btn>
</div>
</main>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import Panel from '@/components/Panel.vue'
import { wrapValue } from './libs/math'
import Btn from '@shared/Btn.vue'
const activeIndex = ref(-1)
const drops = ref([])
onMounted(async () => {
const manifest = await fetch('/manifest.json').then((r) => r.json())
drops.value = await Promise.all(
manifest.map(async (name) => {
// Try to fetch metadata, fallback to basic structure if not found
try {
const meta = await fetch(`/drops/${name}/meta.json`).then((r) => r.json())
return {
...meta,
src: `/drops/${name}/index.html`,
}
} catch (e) {
return {
name,
src: `/drops/${name}/index.html`,
}
}
})
)
})
const onPrevious = () => {
console.log(wrapValue(activeIndex.value - 1, 0, drops.value.length - 1))
activeIndex.value = wrapValue(
activeIndex.value - 1,
0,
drops.value.length - 1,
)
}
const onNext = () => {
activeIndex.value = wrapValue(
activeIndex.value + 1,
0,
drops.value.length - 1,
)
}
const activeDrop = computed(() => drops.value[activeIndex.value])
</script>
<style lang="scss">
.container {
.splash, .drops {
background: var(--theme-layout);
color: var(--theme-fg);
height: 100vh;
padding-top: rs(20px);
padding-bottom: rs(20px);
}
.panel {
grid-column: span 4;
}
.drop {
grid-column: 5 / -1;
border-radius: rs(10px);
overflow: hidden;
position: relative;
iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
}
}
}
</style>

View file

@ -0,0 +1,152 @@
<template>
<div class="panel">
<div class="header">
<p class="label">Braindrops #{{ String(index + 1).padStart(2, '0') }}</p>
</div>
<div class="content">
<div v-if="drop.date" class="date meta-item label">
<span class="meta-value">{{ formattedDate }}</span>
</div>
<div v-if="drop.tags && drop.tags.length" class="tags">
<span v-for="tag in drop.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<h1 class="name h2">{{ displayTitle }}</h1>
<p v-if="drop.description" class="description">{{ drop.description }}</p>
</div>
<div class="btn-wrap">
<btn @click="emit('previous')" :disabled="!previousActive">Last Drop</btn>
<btn @click="emit('next')" :disabled="!nextActive">Next Drop</btn>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import Btn from '@shared/Btn.vue'
const props = defineProps({
drop: Object,
index: Number,
previousActive: Boolean,
nextActive: Boolean,
});
const emit = defineEmits(['previous', 'next'])
const displayTitle = computed(() => {
if (props.drop.title) return props.drop.title
// Fallback: format name as title
return props.drop.name
?.split('-')
?.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
})
const formattedDate = computed(() => {
if (!props.drop.date) return ''
const date = new Date(props.drop.date)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
})
</script>
<style lang="scss">
.panel {
background: var(--theme-bg);
border-radius: rs(10px);
padding: var(--layout-margin);
grid-template-rows: auto 1fr auto;
display: grid;
gap: rs(30px);
.header {
text-align: center;
}
.content {
text-align: center;
display: flex;
flex-direction: column;
gap: rs(15px);
.name {
margin-bottom: rs(5px);
}
.description, .date {
color: var(--theme-grey);
}
.meta-item {
font-size: rs(12px);
.meta-label {
opacity: 0.7;
margin-right: rs(8px);
}
.meta-value {
opacity: 0.9;
}
}
.tags {
display: flex;
flex-wrap: wrap;
gap: rs(8px);
justify-content: center;
.tag {
background: rgba(255, 255, 255, 0.1);
padding: rs(4px) rs(12px);
border-radius: rs(12px);
font-size: rs(11px);
text-transform: lowercase;
}
}
.links {
display: flex;
flex-direction: column;
gap: rs(8px);
.link {
color: var(--theme-fg);
text-decoration: none;
font-size: rs(13px);
opacity: 0.8;
transition: opacity 0.2s;
&:hover {
opacity: 1;
text-decoration: underline;
}
}
}
.notes {
font-size: rs(12px);
font-style: italic;
opacity: 0.7;
line-height: 1.4;
margin-top: rs(10px);
}
}
.btn-wrap {
grid-template-columns: 1fr 1fr;
gap: rs(10px);
display: grid;
.btn {
text-align: center;
}
}
}
</style>

28
app/src/libs/math.js Normal file
View file

@ -0,0 +1,28 @@
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
if (value < lowBound) {
return highBound + ((value - lowBound) % range)
}
if (value > highBound) {
return lowBound + ((value - lowBound) % range)
}
return value
}

6
app/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')

0
app/src/styles/main.scss Normal file
View file

13
app/vite.config.js Normal file
View file

@ -0,0 +1,13 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import sharedConfig from '../shared/vite.config.js'
export default defineConfig({
...sharedConfig,
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@shared': fileURLToPath(new URL('../shared', import.meta.url)),
},
},
})

17
bin/buildManifest.js Normal file
View file

@ -0,0 +1,17 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const dropsDir = path.join(__dirname, '../app/public/drops')
const outputPath = path.join(__dirname, '../app/public/manifest.json')
const dropNames = fs
.readdirSync(dropsDir, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name)
fs.writeFileSync(outputPath, JSON.stringify(dropNames, null, 2))
console.log(`Manifest created with ${dropNames.length} drops:`, dropNames)

176
bin/createDrop.js Normal file
View file

@ -0,0 +1,176 @@
import fs from 'fs'
import path from 'path'
import readline from 'readline'
import { spawn } from 'child_process'
const NO_COPY = [
'node_modules',
'dist',
'package-lock.json',
'.git',
'config.js',
]
const templateDir = path.join(process.cwd(), 'template')
const dropsDir = path.join(process.cwd(), 'drops')
const kebabCase = (str) =>
str
.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/[\s_]+/g, '-')
.toLowerCase()
const pascalCase = (str) => {
const camel = str.replace(/[-_\s]+(.)?/g, (_, c) =>
c ? c.toUpperCase() : '',
)
return camel.charAt(0).toUpperCase() + camel.slice(1)
}
const titleCase = (str) => {
const words = str.split('-')
return words
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
const copyDir = (src, dest) => {
fs.mkdirSync(dest, { recursive: true })
const entries = fs.readdirSync(src, { withFileTypes: true })
for (const entry of entries) {
const srcPath = path.join(src, entry.name)
const destPath = path.join(dest, entry.name)
if (NO_COPY.includes(entry.name)) continue
if (entry.isDirectory()) {
copyDir(srcPath, destPath)
} else {
fs.copyFileSync(srcPath, destPath)
}
}
}
const updatePackageJson = (dir, name) => {
const pkgPath = path.join(dir, 'package.json')
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
pkg.name = name
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
}
const replaceFileString = (
dir,
filePath,
oldString,
newString,
newFilePath,
) => {
const fullPath = path.join(dir, filePath)
const fileContent = fs.readFileSync(fullPath, 'utf-8')
const updatedContent = fileContent.replace(oldString, newString)
if (newFilePath) {
fs.writeFileSync(path.join(dir, newFilePath), updatedContent)
} else {
fs.writeFileSync(fullPath, updatedContent)
}
}
const updateViteConfig = (dir, name) => {
replaceFileString(dir, 'vite.config.js', /\$NAME/g, name)
}
const updateVue = (dir, kebabName, pascalName) => {
replaceFileString(dir, 'src/App.vue', /Name/g, pascalName)
replaceFileString(
dir,
'src/components/Name.vue',
/\$name/g,
kebabName,
`src/components/${pascalName}.vue`,
)
fs.unlinkSync(path.join(dir, 'src/components/Name.vue'))
}
const updateIndexHtml = (dir, name) => {
replaceFileString(dir, 'index.html', /\$NAME/g, name)
}
const updateMetaJson = (dir, kebabName, titleName) => {
const metaPath = path.join(dir, 'public/meta.json')
if (fs.existsSync(metaPath)) {
let content = fs.readFileSync(metaPath, 'utf-8')
content = content.replace(/\$name/g, kebabName)
content = content.replace(/\$NAME/g, titleName)
// Update the date to today's date
const today = new Date().toISOString().split('T')[0]
content = content.replace(/"date":\s*"[^"]*"/, `"date": "${today}"`)
fs.writeFileSync(metaPath, content)
}
}
const runCommand = (command, args, cwd = process.cwd()) => {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: 'inherit',
shell: true,
})
child.on('close', (code) => {
if (code === 0) resolve()
else reject(new Error(`Command failed with code ${code}`))
})
child.on('error', reject)
})
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
rl.question('Enter drop name: ', async (name) => {
const trimmedName = name.trim()
const kebabName = kebabCase(trimmedName)
const pascalName = pascalCase(trimmedName)
const titleName = titleCase(kebabName)
if (!kebabName) {
console.error('Error: drop name cannot be empty')
rl.close()
process.exit(1)
}
const destDir = path.join(dropsDir, kebabName)
if (fs.existsSync(destDir)) {
console.error(`Error: drop "${kebabName}" already exists`)
rl.close()
process.exit(1)
}
try {
console.log(`Creating drop "${kebabName}"...`)
copyDir(templateDir, destDir)
// Update template files
updatePackageJson(destDir, kebabName)
updateViteConfig(destDir, kebabName)
updateVue(destDir, kebabName, pascalName)
updateIndexHtml(destDir, titleName)
updateMetaJson(destDir, kebabName, titleName)
console.log(`Drop "${kebabName}" created successfully`)
console.log('Running npm install...')
await runCommand('npm', ['install'])
console.log('npm install complete!')
} catch (err) {
console.error('Error:', err.message)
process.exit(1)
}
rl.close()
})

72
bin/dev.js Normal file
View file

@ -0,0 +1,72 @@
import { spawn } from 'child_process'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const dropsDir = path.join(__dirname, '../drops')
// Get drop name from command line args
const dropName = process.argv[2]
if (!dropName) {
console.error('❌ Error: Please specify a drop name')
console.log('Usage: npm run dev <drop-name>')
console.log('\nAvailable drops:')
const drops = fs
.readdirSync(dropsDir, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name)
drops.forEach((drop) => console.log(` - ${drop}`))
process.exit(1)
}
const dropPath = path.join(dropsDir, dropName)
if (!fs.existsSync(dropPath)) {
console.error(`❌ Error: Drop "${dropName}" does not exist`)
process.exit(1)
}
console.log(`\n🚀 Starting dev environment for: ${dropName}\n`)
console.log(`📦 Drop dev server: http://localhost:3000`)
console.log(`📝 CMS: http://localhost:8787\n`)
// Start drop dev server
const dropProcess = spawn('npm', ['run', 'dev'], {
cwd: dropPath,
stdio: 'inherit',
shell: true,
})
// Start CMS with DROP_NAME environment variable
const cmsProcess = spawn('npm', ['run', 'dev', '-w', 'cms'], {
cwd: path.join(__dirname, '..'),
stdio: 'inherit',
shell: true,
env: {
...process.env,
DROP_NAME: dropName,
},
})
// Handle process cleanup
const cleanup = () => {
console.log('\n\n🛑 Shutting down dev servers...')
dropProcess.kill()
cmsProcess.kill()
process.exit(0)
}
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
dropProcess.on('error', (err) => {
console.error('❌ Drop server error:', err)
cleanup()
})
cmsProcess.on('error', (err) => {
console.error('❌ CMS server error:', err)
cleanup()
})

13
cms/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>braindrops</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

25
cms/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "braindrops-cms",
"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": {
"sass": "^1.98.0",
"sass-embedded": "^1.98.0",
"vue": "^3.5.30"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.0"
}
}

BIN
cms/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

17
cms/src/App.vue Normal file
View file

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

View file

@ -0,0 +1,356 @@
<template>
<div class="meta-editor">
<div v-if="loading" class="loading">Loading metadata...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="!dropName" class="error">
No drop specified. Please run CMS with a drop name.
</div>
<form v-else @submit.prevent="saveMeta" class="meta-form">
<div class="form-header">
<h1 class="h2">Edit Metadata: {{ dropName }}</h1>
</div>
<!-- Name (readonly, identifier) -->
<div class="form-group">
<label for="name">Name (identifier)</label>
<input
id="name"
v-model="meta.name"
type="text"
readonly
class="readonly"
/>
<small>This is the drop's identifier and cannot be changed.</small>
</div>
<!-- Title -->
<div class="form-group">
<label for="title">Title</label>
<input
id="title"
v-model="meta.title"
type="text"
required
placeholder="Display title"
/>
</div>
<!-- Description -->
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
v-model="meta.description"
rows="3"
required
placeholder="Brief description of what this drop does"
></textarea>
</div>
<!-- Date -->
<div class="form-group">
<label for="date">Date</label>
<input
id="date"
v-model="meta.date"
type="date"
required
/>
</div>
<!-- Tags -->
<div class="form-group">
<label>Tags</label>
<div class="tags-editor">
<div class="tag-list">
<span
v-for="(tag, index) in meta.tags"
:key="index"
class="tag"
>
{{ tag }}
<button
type="button"
@click="removeTag(index)"
class="tag-remove"
>
×
</button>
</span>
</div>
<div class="tag-input-wrapper">
<input
v-model="newTag"
type="text"
placeholder="Add a tag"
@keypress.enter.prevent="addTag"
/>
<btn type="button" @click="addTag">
Add Tag
</btn>
</div>
</div>
</div>
<!-- Actions -->
<div class="form-actions">
<btn type="submit" :disabled="saving">
{{ saving ? 'Saving...' : 'Save Changes' }}
</btn>
<btn type="button" @click="loadMeta">
Reset
</btn>
</div>
<div v-if="saveSuccess" class="success-wrapper">
<div class="success-message">
<span>
Changes saved successfully!
</span>
</div>
</div>
</form>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import Btn from '@shared/Btn.vue'
const dropName = ref(null)
const meta = ref({
name: '',
title: '',
description: '',
date: '',
tags: [],
links: [],
notes: '',
})
const loading = ref(true)
const error = ref(null)
const saving = ref(false)
const saveSuccess = ref(false)
const newTag = ref('')
// Load drop info
const loadDropInfo = async () => {
try {
const res = await fetch('/api/drop-info')
const data = await res.json()
dropName.value = data.dropName
} catch (err) {
console.error('Failed to load drop info:', err)
}
}
// Load metadata
const loadMeta = async () => {
loading.value = true
error.value = null
try {
const res = await fetch('/api/meta')
if (!res.ok) {
throw new Error(`Failed to load metadata: ${res.statusText}`)
}
const data = await res.json()
meta.value = data
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
// Save metadata
const saveMeta = async () => {
saving.value = true
saveSuccess.value = false
error.value = null
try {
const res = await fetch('/api/meta', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(meta.value),
})
if (!res.ok) {
throw new Error(`Failed to save metadata: ${res.statusText}`)
}
saveSuccess.value = true
setTimeout(() => {
saveSuccess.value = false
}, 3000)
} catch (err) {
error.value = err.message
} finally {
saving.value = false
}
}
// Tag management
const addTag = () => {
const tag = newTag.value.trim()
if (tag && !meta.value.tags.includes(tag)) {
meta.value.tags.push(tag)
newTag.value = ''
}
}
const removeTag = (index) => {
meta.value.tags.splice(index, 1)
}
onMounted(async () => {
await loadDropInfo()
if (dropName.value) {
await loadMeta()
} else {
loading.value = false
}
})
</script>
<style lang="scss" scoped>
.meta-editor {
max-width: rs(800px);
margin: 0 auto;
padding: 2rem;
.loading,
.error {
text-align: center;
padding: 2rem;
}
.error {
color: var(--clay);
}
.meta-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-header {
margin-bottom: 1rem;
h1 {
margin: 0 0 0.5rem;
}
.drop-name {
margin: 0;
color: var(--theme-grey);
}
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
input,
textarea {
padding: 0.75rem;
border: 1px solid var(--theme-grey);
border-radius: rs(10px);
font-family: inherit;
&:focus {
outline: none;
border-color: var(--sea);
}
&.readonly {
background: var(--theme-layout);
pointer-events: none;
}
}
textarea {
resize: vertical;
}
small {
color: var(--theme-grey);
}
}
.tags-editor {
display: flex;
flex-direction: column;
gap: 0.75rem;
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: rs(6px) rs(16px);
background: var(--theme-shade);
border-radius: rs(8px);
@include label;
.tag-remove {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0;
&:hover {
color: var(--theme-contrast);
}
}
}
.tag-input-wrapper {
display: flex;
gap: 0.5rem;
align-items: center;
}
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.success-wrapper {
position: fixed;
bottom: 0;
left: 50%;
width: rs(800px);
padding: 2rem;
transform: translateX(-50%);
display: flex;
justify-content: flex-end;
.success-message {
padding: 1rem;
background: var(--forest-shade);
border: 1px solid var(--forest);
border-radius: rs(8px);
color: var(--forest);
text-align: center;
animation: fadeIn 0.3s;
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

28
cms/src/libs/math.js Normal file
View file

@ -0,0 +1,28 @@
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
if (value < lowBound) {
return highBound + ((value - lowBound) % range)
}
if (value > highBound) {
return lowBound + ((value - lowBound) % range)
}
return value
}

View file

@ -0,0 +1,97 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export function metaApiPlugin() {
let dropName = null
return {
name: 'meta-api-plugin',
configureServer(server) {
// Get the drop name from environment variable
dropName = process.env.DROP_NAME
if (!dropName) {
console.warn('⚠️ DROP_NAME not set. CMS will not be able to load meta.json')
} else {
console.log(`📝 CMS connected to drop: ${dropName}`)
}
server.middlewares.use('/api/meta', async (req, res, next) => {
if (!dropName) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'No drop specified' }))
return
}
const metaPath = path.join(
__dirname,
'../../../drops',
dropName,
'public/meta.json'
)
// GET - Read meta.json
if (req.method === 'GET') {
try {
if (!fs.existsSync(metaPath)) {
res.statusCode = 404
res.end(JSON.stringify({ error: 'meta.json not found' }))
return
}
const metaContent = fs.readFileSync(metaPath, 'utf-8')
res.setHeader('Content-Type', 'application/json')
res.end(metaContent)
} catch (err) {
res.statusCode = 500
res.end(JSON.stringify({ error: err.message }))
}
return
}
// POST - Write meta.json
if (req.method === 'POST') {
let body = ''
req.on('data', (chunk) => {
body += chunk.toString()
})
req.on('end', () => {
try {
// Validate JSON
const metaData = JSON.parse(body)
// Write to file with pretty formatting
fs.writeFileSync(
metaPath,
JSON.stringify(metaData, null, 2),
'utf-8'
)
console.log(`✅ Updated meta.json for ${dropName}`)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ success: true }))
} catch (err) {
res.statusCode = 500
res.end(JSON.stringify({ error: err.message }))
}
})
return
}
// Method not allowed
res.statusCode = 405
res.end(JSON.stringify({ error: 'Method not allowed' }))
})
// Endpoint to get the current drop name
server.middlewares.use('/api/drop-info', (req, res) => {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ dropName }))
})
},
}
}

6
cms/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')

0
cms/src/styles/main.scss Normal file
View file

46
cms/vite.config.js Normal file
View file

@ -0,0 +1,46 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import config from '../shared/config.js'
import { toSass } from '../shared/libs/sass/index.js'
import { metaApiPlugin } from './src/libs/vite-plugin-meta-api.js'
export default defineConfig({
plugins: [vue(), metaApiPlugin()],
server: {
port: 8787,
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@shared': fileURLToPath(new URL('../shared', import.meta.url)),
},
},
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler',
additionalData:
'@use "@shared/styles/_functions.scss" as *; @use "@shared/styles/_font-style.scss" as *;',
functions: {
'get($keys)': function (keys) {
keys = keys.toString().replace(/['"]+/g, '').split('.')
let result = config
for (let i = 0; i < keys.length; i++) {
result = result[keys[i]]
}
return toSass(result)
},
'getColors()': function () {
return toSass(config.colors)
},
'getThemes()': function () {
return toSass(config.themes)
},
},
},
},
},
})

13
drops/flowmap/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>Flowmap</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View file

@ -0,0 +1,27 @@
{
"name": "flowmap",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fuzzco/font-loader": "^1.0.2",
"@tresjs/cientos": "^5.8.1",
"@tresjs/core": "^5.8.3",
"@vueuse/core": "^14.3.0",
"sass": "^1.98.0",
"sass-embedded": "^1.98.0",
"three": "^0.185.1",
"three-stdlib": "^2.36.1",
"vite-plugin-glsl": "^1.6.0",
"vue": "^3.5.30"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,9 @@
{
"name": "flowmap",
"title": "Flowmap Test",
"description": "An interactive flowmap visualization experiment and more",
"date": "2026-07-16",
"tags": [
"visualization"
]
}

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

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

View file

@ -0,0 +1,144 @@
<template>
<primitive v-if="arrowMesh" :object="arrowMesh" />
</template>
<script setup>
import { InstancedMesh, ShaderMaterial, InstancedBufferAttribute } from 'three'
import { createArrowGeometry } from '@/libs/three/createArrowGeometry'
import FluidSimulation from '@/libs/glsl/fluid/fluidSimulation'
import fragmentShader from '@/libs/glsl/arrows/fragment.glsl'
import vertexShader from '@/libs/glsl/arrows/vertex.glsl'
import { shallowRef, watch, onMounted } from 'vue'
import { useLoop, useTres } from '@tresjs/core'
import { Color } from 'three'
const props = defineProps({
gridSize: {
type: Number,
default: 50,
},
width: {
type: Number,
default: 2,
},
height: {
type: Number,
default: 2,
},
arrowScale: {
type: Number,
default: 0.015,
},
scaleMultiplier: {
type: Number,
default: 2.0,
},
color: {
type: String,
default: '#ffffff',
},
})
const arrowMesh = shallowRef(null)
const { renderer } = useTres()
const { onRender } = useLoop()
// Fluid simulation
const simulation = new FluidSimulation({
renderer: renderer,
size: 1280,
})
onRender(() => {
if (arrowMesh.value) {
arrowMesh.value.material.uniforms.u_velocity.value = simulation.update()
}
})
onMounted(() => {
createArrowField()
})
const createArrowField = () => {
const geometry = createArrowGeometry()
const instanceCount = props.gridSize * props.gridSize
// Create instanced buffer attribute for grid positions
const instanceGrid = new Float32Array(instanceCount * 2)
let idx = 0
for (let y = 0; y < props.gridSize; y++) {
for (let x = 0; x < props.gridSize; x++) {
instanceGrid[idx++] = x
instanceGrid[idx++] = y
}
}
// Add instance attribute to geometry
geometry.setAttribute(
'instanceGrid',
new InstancedBufferAttribute(instanceGrid, 2),
)
// Parse color
const color = new Color(props.color)
// Auto-calculate spacing to fill viewport
// Use the larger dimension to ensure coverage
const spacingX = props.width / (props.gridSize - 1)
const spacingY = props.height / (props.gridSize - 1)
const spacing = Math.max(spacingX, spacingY)
// Create shader material
const material = new ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
u_velocity: { value: simulation.update() },
u_grid_size: { value: [props.gridSize, props.gridSize] },
u_spacing: { value: spacing },
u_arrow_scale: { value: props.arrowScale },
u_scale_multiplier: { value: props.scaleMultiplier },
u_color: { value: color },
},
})
// Create instanced mesh
arrowMesh.value = new InstancedMesh(geometry, material, instanceCount)
arrowMesh.value.frustumCulled = false
}
// Watch for prop changes
watch(
() => [
props.gridSize,
props.width,
props.height,
props.arrowScale,
props.scaleMultiplier,
props.color,
],
() => {
if (arrowMesh.value) {
// Recalculate spacing
const spacingX = props.width / (props.gridSize - 1)
const spacingY = props.height / (props.gridSize - 1)
const spacing = Math.max(spacingX, spacingY)
arrowMesh.value.material.uniforms.u_grid_size.value = [
props.gridSize,
props.gridSize,
]
arrowMesh.value.material.uniforms.u_spacing.value = spacing
arrowMesh.value.material.uniforms.u_arrow_scale.value =
props.arrowScale
arrowMesh.value.material.uniforms.u_scale_multiplier.value =
props.scaleMultiplier
arrowMesh.value.material.uniforms.u_color.value = new Color(
props.color,
)
}
},
)
</script>

View file

@ -0,0 +1,152 @@
<template>
<component
class="btn"
:is="tag"
>
<span v-if="arrow" class="leading-icon"></span>
<span class="text">
<span><slot /></span>
<span><slot /></span>
</span>
<span v-if="arrow" class="arrow"></span>
<!-- <span v-if="loading" class="loading">
<svg-util-spinner />
</span> -->
</component>
</template>
<script setup>
const props = defineProps({
tag: {
type: String,
default: () => 'button',
},
arrow: {
type: Boolean,
default: () => false,
},
loading: {
type: Boolean,
default: () => false,
},
})
</script>
<style lang="scss">
.btn {
--t-duration: 350ms;
--t-ease: var(--ease-out-quad);
display: inline-flex;
align-items: center;
padding: rs(6px) rs(16px);
background: var(--theme-contrast);
color: var(--ivory);
border-radius: rs(8px);
position: relative;
overflow: hidden;
cursor: pointer;
transition: color var(--t-duration) var(--t-ease);
.leading-icon {
position: absolute;
width: rs(10px);
height: 1em;
display: flex;
align-items: center;
transform: translateX(-100%);
opacity: 0;
transition: transform var(--t-duration) var(--t-ease),
opacity var(--t-duration) var(--t-ease);
}
.text {
position: relative;
display: block;
overflow: hidden;
transition: transform var(--t-duration) var(--t-ease);
span {
display: inline-block;
transition: transform var(--t-duration) var(--t-ease);
@include label;
}
span:first-child {
color: var(--ivory);
}
span:last-child {
position: absolute;
left: 0;
right: 0;
bottom: 0;
transform: translateY(105%);
color: var(--ivory);
}
}
.arrow {
transition: transform var(--t-duration) var(--t-ease),
opacity var(--t-duration) var(--t-ease);
&:not(:only-child) {
margin-left: 0.5em;
}
}
.loading {
width: rs(10px);
height: 1em;
display: flex;
align-items: center;
svg {
width: 100%;
height: auto;
}
}
&::before {
content: '';
display: block;
position: absolute;
inset: -2px;
background: var(--theme-contrast);
clip-path: inset(100% 0 0 0);
transition: clip-path var(--t-duration) var(--t-ease);
}
&:hover,
&.active,
&.router-link-active {
color: var(--ivory);
&::before {
clip-path: inset(0);
}
.text {
span:first-child {
transform: translateY(-105%);
}
span:last-child {
transform: none;
}
}
&.text-icon {
.leading-icon {
transform: none;
opacity: 1;
}
.text {
transform: translateX(1.5em);
}
.arrow {
transform: translateX(100%);
opacity: 0;
}
}
}
&:disabled,
&.disabled {
opacity: 0.16;
pointer-events: none;
}
}
</style>

View file

@ -0,0 +1,26 @@
<template>
<div class="flowmap">
<webgl-canvas>
<webgl-arrow-field
:width="1"
:height="2"
:gridSize="200"
:arrowScale="0.01"
:scaleMultiplier="1.7"
:color="themes.light.fg"
/>
</webgl-canvas>
</div>
</template>
<script setup>
import WebglCanvas from './WebglCanvas.vue';
import WebglArrowField from './ArrowField.vue';
import { themes } from '@shared/config';
</script>
<style lang="scss">
.flowmap {
height: 100%;
}
</style>

View file

@ -0,0 +1,77 @@
<template>
<div :class="['webgl-canvas', { controls }]">
<TresCanvas
v-bind="{
powerPreference: 'high-performance',
alpha: true,
clearAlpha: 0,
}"
windowSize
>
<TresPerspectiveCamera
v-if="perspective"
:position="[0, 0, 0]"
:near="0.001"
:far="1000"
/>
<TresOrthographicCamera
v-else
:args="[0, 1, 0, 1, 0.001, 5000]"
:position="[0, 0, -1000]"
:look-at="[0, 0, 0]"
/>
<!-- Lighting -->
<TresAmbientLight :intensity="1.2" />
<TresDirectionalLight
:position="[width * -0.25, height * 1.3, width * 0.25]"
:intensity="3"
cast-shadow
/>
<TresDirectionalLight
:position="[width * 0.8, height * -0.9, width * 0.25]"
:intensity="3"
cast-shadow
/>
<OrbitControls v-if="controls" />
<slot />
</TresCanvas>
</div>
</template>
<script setup>
import { inject } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { TresCanvas } from '@tresjs/core'
import { OrbitControls } from '@tresjs/cientos'
const { controls, perspective } = defineProps({
controls: Boolean,
perspective: {
type: Boolean,
default: () => false,
},
})
const { width, height } = useWindowSize()
</script>
<style lang="scss">
.webgl-canvas {
position: fixed;
inset: 0;
&:not(.controls) {
pointer-events: none;
.sticky canvas {
pointer-events: none !important;
}
}
}
</style>

View file

@ -0,0 +1,25 @@
precision highp float;
uniform vec3 u_color;
// Varying from vertex shader
varying vec2 v_uv;
varying float v_velocity_magnitude;
void main() {
vec3 color = u_color;
// if (u_colorByVelocity) {
// // Color based on velocity intensity
// // Low velocity = dim, high velocity = bright
// float intensity = clamp(v_velocity_magnitude * 8.0, 0.0, 1.0);
// // Gradient from dark blue/grey to bright cyan/white
// vec3 lowColor = vec3(0.2, 0.2, 0.3);
// vec3 highColor = vec3(0.4, 0.8, 1.0);
// color = mix(lowColor, highColor, intensity);
// }
gl_FragColor = vec4(color, 1.0);
}

View file

@ -0,0 +1,53 @@
precision highp float;
float PI = 3.14159265358979323846;
// Per-instance attribute - grid coordinates for each arrow
attribute vec2 instanceGrid;
uniform sampler2D u_velocity;
uniform vec2 u_grid_size;
uniform float u_spacing;
uniform float u_arrow_scale;
uniform float u_scale_multiplier;
varying vec2 v_uv;
varying float v_velocity_magnitude;
void main() {
// Calculate UV coordinates for velocity texture sampling
// Normalize grid position to 0-1 range
v_uv = instanceGrid / u_grid_size;
// Sample velocity from texture
vec2 velocity = texture2D(u_velocity, v_uv).rg;
// Flip Y to match Three.js coordinate system
velocity.y = -velocity.y;
// Calculate rotation angle from velocity vector
float angle = atan(velocity.y, velocity.x);
angle -= PI / 2.;
// Calculate scale based on velocity magnitude
v_velocity_magnitude = length(velocity);
float scale = u_arrow_scale + (v_velocity_magnitude / 50000.) * u_scale_multiplier;
// Create 2D rotation matrix
mat2 rotation = mat2(
cos(angle), -sin(angle),
sin(angle), cos(angle)
);
// Apply rotation and scale to arrow vertices
vec2 rotatedPos = rotation * position.xy * scale;
// Calculate world position from grid coordinates
// Center the grid around origin
vec2 worldPos = (instanceGrid * u_spacing) - (u_grid_size * u_spacing * 0.5);
worldPos += rotatedPos;
// Transform to clip space
vec4 mvPosition = modelViewMatrix * vec4(worldPos, 0.0, 1.0);
gl_Position = projectionMatrix * mvPosition;
}

View file

@ -0,0 +1,786 @@
/* eslint-disable */
// https://github.com/oframe/ogl/blob/master/examples/post-fluid-distortion.html
import {
FloatType,
HalfFloatType,
LinearFilter,
NearestFilter,
RedFormat,
RGBAFormat,
RGFormat,
ShaderMaterial,
Vector2,
Vector3,
WebGLRenderTarget,
} from 'three'
import Program from './program'
const fragment = /* glsl */ `
precision highp float;
uniform sampler2D tMap;
uniform sampler2D tFluid;
uniform float uTime;
varying vec2 vUv;
void main() {
vec3 fluid = texture2D(tFluid, vUv).rgb;
vec2 uv = vUv - fluid.rg * 0.0002;
gl_FragColor = mix( texture2D(tMap, uv), vec4(fluid * 0.1 + 0.5, 1), step(0.5, vUv.x) ) ;
}
`
const baseVertex = /* glsl */ `
precision highp float;
varying vec2 vUv;
varying vec2 vL;
varying vec2 vR;
varying vec2 vT;
varying vec2 vB;
uniform vec2 texelSize;
void main () {
vUv = uv;
vL = vUv - vec2(texelSize.x, 0.0);
vR = vUv + vec2(texelSize.x, 0.0);
vT = vUv + vec2(0.0, texelSize.y);
vB = vUv - vec2(0.0, texelSize.y);
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
`
const clearShader = /* glsl */ `
precision mediump float;
precision mediump sampler2D;
varying highp vec2 vUv;
uniform sampler2D uTexture;
uniform float value;
void main () {
gl_FragColor = value * texture2D(uTexture, vUv);
}
`
const splatShader = /* glsl */ `
precision highp float;
precision highp sampler2D;
varying vec2 vUv;
uniform sampler2D uTarget;
uniform float aspectRatio;
uniform vec3 color;
uniform vec2 point;
uniform float radius;
void main () {
vec2 p = vUv - point.xy;
p.x *= aspectRatio;
vec3 splat = exp(-dot(p, p) / radius) * color;
vec3 base = texture2D(uTarget, vUv).xyz;
gl_FragColor = vec4(base + splat, 1.0);
}
`
const advectionManualFilteringShader = /* glsl */ `
precision highp float;
precision highp sampler2D;
varying vec2 vUv;
uniform sampler2D uVelocity;
uniform sampler2D uSource;
uniform vec2 texelSize;
uniform vec2 dyeTexelSize;
uniform float dt;
uniform float dissipation;
vec4 bilerp (sampler2D sam, vec2 uv, vec2 tsize) {
vec2 st = uv / tsize - 0.5;
vec2 iuv = floor(st);
vec2 fuv = fract(st);
vec4 a = texture2D(sam, (iuv + vec2(0.5, 0.5)) * tsize);
vec4 b = texture2D(sam, (iuv + vec2(1.5, 0.5)) * tsize);
vec4 c = texture2D(sam, (iuv + vec2(0.5, 1.5)) * tsize);
vec4 d = texture2D(sam, (iuv + vec2(1.5, 1.5)) * tsize);
return mix(mix(a, b, fuv.x), mix(c, d, fuv.x), fuv.y);
}
void main () {
vec2 coord = vUv - dt * bilerp(uVelocity, vUv, texelSize).xy * texelSize;
gl_FragColor = dissipation * bilerp(uSource, coord, dyeTexelSize);
gl_FragColor.a = 1.0;
}
`
const advectionShader = /* glsl */ `
precision highp float;
precision highp sampler2D;
varying vec2 vUv;
uniform sampler2D uVelocity;
uniform sampler2D uSource;
uniform vec2 texelSize;
uniform float dt;
uniform float dissipation;
void main () {
vec2 coord = vUv - dt * texture2D(uVelocity, vUv).xy * texelSize;
gl_FragColor = dissipation * texture2D(uSource, coord);
gl_FragColor.a = 1.0;
}
`
const divergenceShader = /* glsl */ `
precision mediump float;
precision mediump sampler2D;
varying highp vec2 vUv;
varying highp vec2 vL;
varying highp vec2 vR;
varying highp vec2 vT;
varying highp vec2 vB;
uniform sampler2D uVelocity;
void main () {
float L = texture2D(uVelocity, vL).x;
float R = texture2D(uVelocity, vR).x;
float T = texture2D(uVelocity, vT).y;
float B = texture2D(uVelocity, vB).y;
vec2 C = texture2D(uVelocity, vUv).xy;
if (vL.x < 0.0) { L = -C.x; }
if (vR.x > 1.0) { R = -C.x; }
if (vT.y > 1.0) { T = -C.y; }
if (vB.y < 0.0) { B = -C.y; }
float div = 0.5 * (R - L + T - B);
gl_FragColor = vec4(div, 0.0, 0.0, 1.0);
}
`
const curlShader = /* glsl */ `
precision mediump float;
precision mediump sampler2D;
varying highp vec2 vUv;
varying highp vec2 vL;
varying highp vec2 vR;
varying highp vec2 vT;
varying highp vec2 vB;
uniform sampler2D uVelocity;
void main () {
float L = texture2D(uVelocity, vL).y;
float R = texture2D(uVelocity, vR).y;
float T = texture2D(uVelocity, vT).x;
float B = texture2D(uVelocity, vB).x;
float vorticity = R - L - T + B;
gl_FragColor = vec4(0.5 * vorticity, 0.0, 0.0, 1.0);
}
`
const vorticityShader = /* glsl */ `
precision highp float;
precision highp sampler2D;
varying vec2 vUv;
varying vec2 vL;
varying vec2 vR;
varying vec2 vT;
varying vec2 vB;
uniform sampler2D uVelocity;
uniform sampler2D uCurl;
uniform float curl;
uniform float dt;
void main () {
float L = texture2D(uCurl, vL).x;
float R = texture2D(uCurl, vR).x;
float T = texture2D(uCurl, vT).x;
float B = texture2D(uCurl, vB).x;
float C = texture2D(uCurl, vUv).x;
vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L));
force /= length(force) + 0.0001;
force *= curl * C;
force.y *= -1.0;
vec2 vel = texture2D(uVelocity, vUv).xy;
gl_FragColor = vec4(vel + force * dt, 0.0, 1.0);
}
`
const pressureShader = /* glsl */ `
precision mediump float;
precision mediump sampler2D;
varying highp vec2 vUv;
varying highp vec2 vL;
varying highp vec2 vR;
varying highp vec2 vT;
varying highp vec2 vB;
uniform sampler2D uPressure;
uniform sampler2D uDivergence;
void main () {
float L = texture2D(uPressure, vL).x;
float R = texture2D(uPressure, vR).x;
float T = texture2D(uPressure, vT).x;
float B = texture2D(uPressure, vB).x;
float C = texture2D(uPressure, vUv).x;
float divergence = texture2D(uDivergence, vUv).x;
float pressure = (L + R + B + T - divergence) * 0.25;
gl_FragColor = vec4(pressure, 0.0, 0.0, 1.0);
}
`
const gradientSubtractShader = /* glsl */ `
precision mediump float;
precision mediump sampler2D;
varying highp vec2 vUv;
varying highp vec2 vL;
varying highp vec2 vR;
varying highp vec2 vT;
varying highp vec2 vB;
uniform sampler2D uPressure;
uniform sampler2D uVelocity;
void main () {
float L = texture2D(uPressure, vL).x;
float R = texture2D(uPressure, vR).x;
float T = texture2D(uPressure, vT).x;
float B = texture2D(uPressure, vB).x;
vec2 velocity = texture2D(uVelocity, vUv).xy;
velocity.xy -= vec2(R - L, T - B);
gl_FragColor = vec4(velocity, 0.0, 1.0);
}
`
function createDoubleFBO(
width,
height,
{
wrapS,
wrapT,
minFilter = LinearFilter,
magFilter = minFilter,
format = RGBAFormat,
internalFormat,
type,
depthBuffer,
stencilBuffer,
} = {},
) {
const fbo = {
read: new WebGLRenderTarget(width, height, {
wrapS,
wrapT,
minFilter,
magFilter,
format,
type,
depthBuffer,
stencilBuffer,
}),
write: new WebGLRenderTarget(width, height, {
wrapS,
wrapT,
minFilter,
magFilter,
format,
type,
depthBuffer,
stencilBuffer,
}),
swap: () => {
const temp = fbo.read
fbo.read = fbo.write
fbo.write = temp
},
}
if (internalFormat) {
fbo.read.texture.internalFormat = fbo.write.texture.internalFormat =
internalFormat
}
return fbo
}
function getTHREEFormat(format) {
switch (format) {
case 34842:
return 'RGBA16F'
case 33327:
return 'RG16F'
case 33325:
return 'R16F'
case 6408:
return RGBAFormat
case 33319:
return RGFormat
case 6403:
return RedFormat
default:
break
}
}
function supportRenderTextureFormat(gl, internalFormat, format, type) {
let texture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texImage2D(
gl.TEXTURE_2D,
0,
internalFormat,
gl.RGBA,
gl.RGBA,
0,
format,
type,
null,
)
let fbo = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
texture,
0,
)
let status = gl.checkFramebufferStatus(gl.FRAMEBUFFER)
return status == gl.FRAMEBUFFER_COMPLETE
}
// Helper functions for more device support
function getSupportedFormat(gl, internalFormat, format, type) {
if (!supportRenderTextureFormat(gl, internalFormat, format, type)) {
switch (internalFormat) {
case gl.R16F:
return getSupportedFormat(gl, gl.RG16F, gl.RG, type)
case gl.RG16F:
return getSupportedFormat(gl, gl.RGBA16F, gl.RGBA, type)
default:
return null
}
}
return {
internalFormat: getTHREEFormat(internalFormat),
format: getTHREEFormat(format),
}
}
export default class FluidSimulation {
constructor({ renderer, size = 128 } = {}) {
this.renderer = renderer
// Resolution of simulation
this.simRes = size
this.dyeRes = 512
// Main inputs to control look and feel of fluid
this.iterations = 3
this.densityDissipation = 0.97
this.velocityDissipation = 0.98
this.pressureDissipation = 0.8
this.curlStrength = 20
this.radius = 0.2
// Common uniform
this.texelSize = {
value: new Vector2(1 / this.simRes, 1 / this.simRes),
}
const gl = this.renderer.getContext()
const isWebGL2 = this.renderer.capabilities.isWebGL2
let halfFloat
let supportLinearFiltering
if (isWebGL2) {
gl.getExtension('EXT_color_buffer_float')
supportLinearFiltering = gl.getExtension('OES_texture_float_linear')
} else {
halfFloat = gl.getExtension('OES_texture_half_float')
supportLinearFiltering = gl.getExtension(
'OES_texture_half_float_linear',
)
}
const halfFloatTexType = isWebGL2
? gl.HALF_FLOAT
: halfFloat.HALF_FLOAT_OES
let rgba
let rg
let r
if (isWebGL2) {
rgba = getSupportedFormat(gl, gl.RGBA16F, gl.RGBA, halfFloatTexType)
rg = getSupportedFormat(gl, gl.RG16F, gl.RG, halfFloatTexType)
r = getSupportedFormat(gl, gl.R16F, gl.RED, halfFloatTexType)
} else {
rgba = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType)
rg = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType)
r = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType)
}
const filtering = supportLinearFiltering ? LinearFilter : NearestFilter
halfFloat = isWebGL2 ? FloatType : HalfFloatType
// Create fluid simulation FBOs
this.density = createDoubleFBO(this.dyeRes, this.dyeRes, {
type: halfFloat,
minFilter: filtering,
format: rgba.format,
internalFormat: rgba.internalFormat,
depthBuffer: false,
stencilBuffer: false,
})
this.velocity = createDoubleFBO(this.simRes, this.simRes, {
type: halfFloat,
minFilter: filtering,
format: rg.format,
internalFormat: rg.internalFormat,
depthBuffer: false,
stencilBuffer: false,
})
this.pressure = createDoubleFBO(this.simRes, this.simRes, {
type: halfFloat,
minFilter: NearestFilter,
format: r.format,
internalFormat: r.internalFormat,
depthBuffer: false,
stencilBuffer: false,
})
this.divergence = new WebGLRenderTarget(this.simRes, this.simRes, {
type: halfFloat,
minFilter: NearestFilter,
format: r.format,
depthBuffer: false,
stencilBuffer: false,
})
this.divergence.texture.internalFormat = r.internalFormat
this.curl = new WebGLRenderTarget(this.simRes, this.simRes, {
type: halfFloat,
minFilter: NearestFilter,
format: r.format,
depthBuffer: false,
stencilBuffer: false,
})
this.curl.texture.internalFormat = r.internalFormat
//programs
this.clearProgram = new Program(
new ShaderMaterial({
vertexShader: baseVertex,
fragmentShader: clearShader,
uniforms: {
texelSize: this.texelSize,
uTexture: { value: null },
value: { value: this.pressureDissipation },
},
depthTest: false,
depthWrite: false,
}),
)
this.splatProgram = new Program(
new ShaderMaterial({
vertexShader: baseVertex,
fragmentShader: splatShader,
uniforms: {
texelSize: this.texelSize,
uTarget: { value: null },
aspectRatio: { value: 1 },
color: { value: new Vector3() },
point: { value: new Vector2() },
radius: { value: 1 },
},
depthTest: false,
depthWrite: false,
}),
)
this.advectionProgram = new Program(
new ShaderMaterial({
vertexShader: baseVertex,
fragmentShader: supportLinearFiltering
? advectionShader
: advectionManualFilteringShader,
uniforms: {
texelSize: this.texelSize,
dyeTexelSize: {
value: new Vector2(1 / this.dyeRes, 1 / this.dyeRes),
},
uVelocity: { value: null },
uSource: { value: null },
dt: { value: 0.016 },
dissipation: { value: 1.0 },
},
depthTest: false,
depthWrite: false,
}),
)
this.divergenceProgram = new Program(
new ShaderMaterial({
vertexShader: baseVertex,
fragmentShader: divergenceShader,
uniforms: {
texelSize: this.texelSize,
uVelocity: { value: null },
},
depthTest: false,
depthWrite: false,
}),
)
this.curlProgram = new Program(
new ShaderMaterial({
vertexShader: baseVertex,
fragmentShader: curlShader,
uniforms: {
texelSize: this.texelSize,
uVelocity: { value: null },
},
depthTest: false,
depthWrite: false,
}),
)
this.vorticityProgram = new Program(
new ShaderMaterial({
vertexShader: baseVertex,
fragmentShader: vorticityShader,
uniforms: {
texelSize: this.texelSize,
uVelocity: { value: null },
uCurl: { value: null },
curl: { value: this.curlStrength },
dt: { value: 0.016 },
},
depthTest: false,
depthWrite: false,
}),
)
this.pressureProgram = new Program(
new ShaderMaterial({
vertexShader: baseVertex,
fragmentShader: pressureShader,
uniforms: {
texelSize: this.texelSize,
uPressure: { value: null },
uDivergence: { value: null },
},
depthTest: false,
depthWrite: false,
}),
)
this.gradienSubtractProgram = new Program(
new ShaderMaterial({
vertexShader: baseVertex,
fragmentShader: gradientSubtractShader,
uniforms: {
texelSize: this.texelSize,
uPressure: { value: null },
uVelocity: { value: null },
},
depthTest: false,
depthWrite: false,
}),
)
this.splats = []
this.lastMouse = new Vector2()
window.addEventListener(
'touchstart',
this.onMouseDown.bind(this),
false,
)
window.addEventListener('mousedown', this.onMouseDown.bind(this), false)
window.addEventListener(
'touchstart',
this.updateMouse.bind(this),
false,
)
window.addEventListener('touchmove', this.updateMouse.bind(this), false)
window.addEventListener('mousemove', this.updateMouse.bind(this), false)
window.addEventListener('touchend', this.onMouseUp.bind(this), false)
window.addEventListener('mouseup', this.onMouseUp.bind(this), false)
}
onMouseDown() {
this.mouseDown = true
}
onMouseUp() {
this.mouseDown = false
}
updateMouse(e) {
// Get canvas element and its bounding rectangle
const canvas = this.renderer.domElement
const rect = canvas.getBoundingClientRect()
// Get client coordinates (viewport-relative)
let clientX, clientY
if (e.changedTouches && e.changedTouches.length) {
clientX = e.changedTouches[0].clientX
clientY = e.changedTouches[0].clientY
} else {
clientX = e.clientX
clientY = e.clientY
}
// Convert to canvas-relative coordinates
const x = clientX - rect.left
const y = clientY - rect.top
if (!this.lastMouse.isInit) {
this.lastMouse.isInit = true
// First input
this.lastMouse.set(x, y)
}
const deltaX = x - this.lastMouse.x
const deltaY = y - this.lastMouse.y
this.lastMouse.set(x, y)
// Add if the mouse is moving
if (Math.abs(deltaX) || Math.abs(deltaY)) {
this.splats.push({
// Map screen coordinates to texture UV coordinates
// Only half the texture is visible due to camera/arrow positioning
x: 0.5 - (x / rect.width) * 0.5,
y: 0.5 + (y / rect.height) * 0.5,
dx: deltaX * 5.0,
dy: deltaY * -5.0,
})
}
}
splat({ x, y, dx, dy }) {
const viewportSize = this.renderer.getSize(new Vector2())
this.splatProgram.program.uniforms.uTarget.value =
this.velocity.read.texture
// Use viewport aspect ratio to correct for non-square windows
this.splatProgram.program.uniforms.aspectRatio.value =
viewportSize.width / viewportSize.height
this.splatProgram.program.uniforms.point.value.set(x, y)
this.splatProgram.program.uniforms.color.value.set(dx, dy, 1.0)
this.splatProgram.program.uniforms.radius.value = this.radius / 100.0
this.renderer.setRenderTarget(this.velocity.write)
this.splatProgram.render(this.renderer)
this.velocity.swap()
this.splatProgram.program.uniforms.uTarget.value =
this.density.read.texture
this.renderer.setRenderTarget(this.density.write)
this.splatProgram.render(this.renderer)
this.density.swap()
}
update(clock) {
// Perform all of the fluid simulation renders
// No need to clear during sim, saving a number of GL calls.
this.renderer.autoClear = false
// Render all of the inputs since last frame
for (let i = this.splats.length - 1; i >= 0; i--) {
this.splat(this.splats.splice(i, 1)[0])
}
this.curlProgram.program.uniforms.uVelocity.value =
this.velocity.read.texture
this.renderer.setRenderTarget(this.curl)
this.curlProgram.render(this.renderer)
this.vorticityProgram.program.uniforms.uVelocity.value =
this.velocity.read.texture
this.vorticityProgram.program.uniforms.uCurl.value = this.curl.texture
this.renderer.setRenderTarget(this.velocity.write)
this.vorticityProgram.render(this.renderer)
this.velocity.swap()
this.divergenceProgram.program.uniforms.uVelocity.value =
this.velocity.read.texture
this.renderer.setRenderTarget(this.divergence)
this.divergenceProgram.render(this.renderer)
this.clearProgram.program.uniforms.uTexture.value =
this.pressure.read.texture
this.clearProgram.program.uniforms.value.value =
this.pressureDissipation
this.renderer.setRenderTarget(this.pressure.write)
this.clearProgram.render(this.renderer)
this.pressure.swap()
this.pressureProgram.program.uniforms.uDivergence.value =
this.divergence.texture
for (let i = 0; i < this.iterations; i++) {
this.pressureProgram.program.uniforms.uPressure.value =
this.pressure.read.texture
this.renderer.setRenderTarget(this.pressure.write)
this.pressureProgram.render(this.renderer)
this.pressure.swap()
}
this.gradienSubtractProgram.program.uniforms.uPressure.value =
this.pressure.read.texture
this.gradienSubtractProgram.program.uniforms.uVelocity.value =
this.velocity.read.texture
this.renderer.setRenderTarget(this.velocity.write)
this.gradienSubtractProgram.render(this.renderer)
this.velocity.swap()
this.advectionProgram.program.uniforms.dyeTexelSize.value.set(
1 / this.simRes,
)
this.advectionProgram.program.uniforms.uVelocity.value =
this.velocity.read.texture
this.advectionProgram.program.uniforms.uSource.value =
this.velocity.read.texture
this.advectionProgram.program.uniforms.dissipation.value =
this.velocityDissipation
this.renderer.setRenderTarget(this.velocity.write)
this.advectionProgram.render(this.renderer)
this.velocity.swap()
this.advectionProgram.program.uniforms.dyeTexelSize.value.set(
1 / this.dyeRes,
)
this.advectionProgram.program.uniforms.uVelocity.value =
this.velocity.read.texture
this.advectionProgram.program.uniforms.uSource.value =
this.density.read.texture
this.advectionProgram.program.uniforms.dissipation.value =
this.densityDissipation
this.renderer.setRenderTarget(this.density.write)
this.advectionProgram.render(this.renderer)
this.density.swap()
// Set clear back to default
this.renderer.autoClear = true
// IMPORTANT: unbind FBO so the main scene renders to the canvas
this.renderer.setRenderTarget(null)
return this.density.read.texture
}
}

View file

@ -0,0 +1,26 @@
import { OrthographicCamera, PlaneGeometry, Mesh, Scene } from 'three'
const geometry = new PlaneGeometry(1, 1)
const camera = new OrthographicCamera(1 / -2, 1 / 2, 1 / 2, 1 / -2, 0.001, 1000)
camera.position.z = 1
export default class Program extends Scene {
constructor(material) {
super()
this.material = material
this.mesh = new Mesh(geometry, this.material)
this.scene = new Scene()
this.scene.add(this.mesh)
}
get program() {
return this.material
}
render(renderer) {
renderer.render(this.scene, camera)
}
}

View file

@ -0,0 +1,57 @@
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
varying vec2 v_uv;
uniform sampler2D tDiffuse;
uniform float u_time;
uniform float u_frame_rate;
uniform float u_opacity;
uniform sampler2D u_flowmap;
uniform float u_min_noise;
vec4 toSRGB (vec4 color) {
return vec4( pow(color.rgb, vec3(1.0 / 2.2)), color.a );
}
float random(vec2 co) {
vec2 p = fract(co * vec2(443.897, 441.423));
p += dot(p, p.yx + 19.19);
return fract(p.x * p.y);
}
void main() {
vec2 st = v_uv.xy;
// flowmap - RG channels contain 2D flow vectors
vec3 flow = texture2D(u_flowmap, st).rgb;
// Convert flow from [0,1] range to [-1,1] range
vec2 flowVector = (flow.rg * 2.0 - 1.0);
// Calculate flow magnitude (how much disturbance/scatter)
float flowMagnitude = length(flowVector * 0.01);
// Invert so high flow = min noise, low flow = 1 (full noise)
// Adjust the power (2.0) to control falloff sharpness
float maskValue = 1.0 - smoothstep(0.0, 1.0, flowMagnitude * 1.0);
// float maskValue = 1.0 - pow(flowMagnitude, 2.0);
float noiseMask = mix(u_min_noise, 1.0, maskValue);
// Get input color from three.js
vec4 original = texture2D(tDiffuse, st);
original = toSRGB(original);
// Random value for each pixel at specified frame rate
float rnd = random(st + floor(u_time * u_frame_rate) / 10000.);
// Apply noise mask - noise only appears where flow is weak
vec4 noise = vec4(vec3(step(0.5, rnd)), u_opacity * noiseMask);
vec4 color = mix(original, noise, noise.a);
gl_FragColor = color;
}

View file

@ -0,0 +1,7 @@
varying vec2 v_uv;
void main() {
v_uv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

View file

@ -0,0 +1,61 @@
/* discontinuous pseudorandom uniformly distributed in [-0.5, +0.5]^3 */
vec3 random3(vec3 c) {
float j = 4096.0 * sin(dot(c, vec3(17.0, 59.4, 15.0)));
vec3 r;
r.z = fract(512.0 * j);
j *= .125;
r.x = fract(512.0 * j);
j *= .125;
r.y = fract(512.0 * j);
return r - 0.5;
}
const float F3 = 0.3333333;
const float G3 = 0.1666667;
/* 3d simplex noise */
float simplex3d(vec3 p) {
/* 1. find current tetrahedron T and it's four vertices */
/* s, s+i1, s+i2, s+1.0 - absolute skewed (integer) coordinates of T vertices */
/* x, x1, x2, x3 - unskewed coordinates of p relative to each of T vertices*/
/* calculate s and x */
vec3 s = floor(p + dot(p, vec3(F3)));
vec3 x = p - s + dot(s, vec3(G3));
/* calculate i1 and i2 */
vec3 e = step(vec3(0.0), x - x.yzx);
vec3 i1 = e * (1.0 - e.zxy);
vec3 i2 = 1.0 - e.zxy * (1.0 - e);
/* x1, x2, x3 */
vec3 x1 = x - i1 + G3;
vec3 x2 = x - i2 + 2.0 * G3;
vec3 x3 = x - 1.0 + 3.0 * G3;
/* 2. find four surflets and store them in d */
vec4 w, d;
/* calculate surflet weights */
w.x = dot(x, x);
w.y = dot(x1, x1);
w.z = dot(x2, x2);
w.w = dot(x3, x3);
/* w fades from 0.6 at the center of the surflet to 0.0 at the margin */
w = max(0.6 - w, 0.0);
/* calculate surflet components */
d.x = dot(random3(s), x);
d.y = dot(random3(s + i1), x1);
d.z = dot(random3(s + i2), x2);
d.w = dot(random3(s + 1.0), x3);
/* multiply d by w^4 */
w *= w;
w *= w;
d *= w;
/* 3. return the sum of the four surflets */
return dot(d, vec4(52.0));
}

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;
}

View file

@ -0,0 +1,46 @@
import { BufferGeometry, Float32BufferAttribute } from 'three'
/**
* Creates a simple 2D arrow geometry
* Arrow points upward (positive Y) when not rotated
* Centered at origin for easy rotation
*/
export function createArrowGeometry() {
const geometry = new BufferGeometry()
// Define arrow shape vertices
// Arrow pointing up: tip at (0, 0.5), base at y = -0.5
const vertices = new Float32Array([
// Triangle 1: Left side of arrowhead
0.0, 0.5, 0.0, // Tip
-0.2, 0.1, 0.0, // Left arrowhead point
-0.1, 0.1, 0.0, // Left shaft top
// Triangle 2: Right side of arrowhead
0.0, 0.5, 0.0, // Tip
-0.1, 0.1, 0.0, // Left shaft top
0.1, 0.1, 0.0, // Right shaft top
// Triangle 3: Right arrowhead point
0.0, 0.5, 0.0, // Tip
0.1, 0.1, 0.0, // Right shaft top
0.2, 0.1, 0.0, // Right arrowhead point
// Triangle 4: Shaft left side
-0.05, 0.1, 0.0, // Left shaft top
-0.05, -0.3, 0.0, // Left shaft bottom
0.05, 0.1, 0.0, // Right shaft top
// Triangle 5: Shaft right side
0.05, 0.1, 0.0, // Right shaft top
-0.05, -0.3, 0.0, // Left shaft bottom
0.05, -0.3, 0.0, // Right shaft bottom
])
geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3))
// Compute normals for proper lighting (if needed later)
geometry.computeVertexNormals()
return geometry
}

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,23 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, mergeConfig } from 'vite'
import sharedConfig from '../../shared/vite.config.js'
import glsl from 'vite-plugin-glsl'
export default defineConfig(
mergeConfig(sharedConfig, {
plugins: [
glsl()
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@shared': fileURLToPath(new URL('../../shared', import.meta.url)),
},
},
base: '/drops/flowmap/',
build: {
outDir: '../../app/public/drops/flowmap',
emptyOutDir: true,
},
})
)

13
drops/testing/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>Testing</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View file

@ -0,0 +1,21 @@
{
"name": "testing",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"sass": "^1.98.0",
"sass-embedded": "^1.98.0",
"vue": "^3.5.30",
"@fuzzco/font-loader": "^1.0.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,14 @@
{
"name": "testing",
"title": "Testing",
"description": "A brief description of what this drop does",
"date": "2026-07-16",
"tags": ["tag1", "tag2", "tag3"],
"links": [
{
"label": "Source Code",
"url": "https://github.com/username/repo"
}
],
"notes": "Any additional notes or thoughts about this drop"
}

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

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

View file

@ -0,0 +1,152 @@
<template>
<component
class="btn"
:is="tag"
>
<span v-if="arrow" class="leading-icon"></span>
<span class="text">
<span><slot /></span>
<span><slot /></span>
</span>
<span v-if="arrow" class="arrow"></span>
<!-- <span v-if="loading" class="loading">
<svg-util-spinner />
</span> -->
</component>
</template>
<script setup>
const props = defineProps({
tag: {
type: String,
default: () => 'button',
},
arrow: {
type: Boolean,
default: () => false,
},
loading: {
type: Boolean,
default: () => false,
},
})
</script>
<style lang="scss">
.btn {
--t-duration: 350ms;
--t-ease: var(--ease-out-quad);
display: inline-flex;
align-items: center;
padding: rs(6px) rs(16px);
background: var(--theme-contrast);
color: var(--ivory);
border-radius: rs(8px);
position: relative;
overflow: hidden;
cursor: pointer;
transition: color var(--t-duration) var(--t-ease);
.leading-icon {
position: absolute;
width: rs(10px);
height: 1em;
display: flex;
align-items: center;
transform: translateX(-100%);
opacity: 0;
transition: transform var(--t-duration) var(--t-ease),
opacity var(--t-duration) var(--t-ease);
}
.text {
position: relative;
display: block;
overflow: hidden;
transition: transform var(--t-duration) var(--t-ease);
span {
display: inline-block;
transition: transform var(--t-duration) var(--t-ease);
@include label;
}
span:first-child {
color: var(--ivory);
}
span:last-child {
position: absolute;
left: 0;
right: 0;
bottom: 0;
transform: translateY(105%);
color: var(--ivory);
}
}
.arrow {
transition: transform var(--t-duration) var(--t-ease),
opacity var(--t-duration) var(--t-ease);
&:not(:only-child) {
margin-left: 0.5em;
}
}
.loading {
width: rs(10px);
height: 1em;
display: flex;
align-items: center;
svg {
width: 100%;
height: auto;
}
}
&::before {
content: '';
display: block;
position: absolute;
inset: -2px;
background: var(--theme-contrast);
clip-path: inset(100% 0 0 0);
transition: clip-path var(--t-duration) var(--t-ease);
}
&:hover,
&.active,
&.router-link-active {
color: var(--ivory);
&::before {
clip-path: inset(0);
}
.text {
span:first-child {
transform: translateY(-105%);
}
span:last-child {
transform: none;
}
}
&.text-icon {
.leading-icon {
transform: none;
opacity: 1;
}
.text {
transform: translateX(1.5em);
}
.arrow {
transform: translateX(100%);
opacity: 0;
}
}
}
&:disabled,
&.disabled {
opacity: 0.16;
pointer-events: none;
}
}
</style>

View file

@ -0,0 +1,32 @@
<template>
<div class="testing">
<h1>Header One</h1>
<h2>Header Two</h2>
<h3>Header Three</h3>
<h4>Header Four</h4>
<p>
Integer at ullamcorper libero, et elementum nulla. Donec at leo in
lorem vehicula lobortis sit amet vitae urna. Vestibulum risus nibh,
vulputate quis velit non, euismod auctor metus. Etiam placerat ut
tortor ut feugiat. Sed id mattis velit.Phasellus ullamcorper commodo
scelerisque. Curabitur pellentesque turpis sem. Sed fermentum, eros
a sagittis sagittis, justo ex fermentum arcu, ac dapibus tellus
ipsum nec sem. Donec tincidunt viverra massa, at suscipit arcu
blandit interdum. Suspendisse turpis eros, lobortis vel dolor
gravida, efficitur elementum urna. Quisque vel justo lacus.
</p>
<btn>Click Here</btn>
</div>
</template>
<script setup>
import Btn from './Btn.vue'
</script>
<style lang="scss">
.testing {
height: 100%;
}
</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;
}

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,50 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import config from '../../shared/config.js'
import { toSass } from '../../shared/libs/sass/index.js'
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@shared': fileURLToPath(new URL('../../shared', import.meta.url)),
},
},
base: '/drops/testing/',
build: {
outDir: '../../app/public/drops/testing',
emptyOutDir: true,
},
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler',
additionalData:
'@use "@shared/styles/_functions.scss" as *; @use "@shared/styles/_font-style.scss" as *;',
functions: {
'get($keys)': function (keys) {
keys = keys.toString().replace(/['"]+/g, '').split('.')
let result = config
for (let i = 0; i < keys.length; i++) {
result = result[keys[i]]
}
return toSass(result)
},
'getColors()': function () {
return toSass(config.colors)
},
'getThemes()': function () {
return toSass(config.themes)
},
},
},
},
},
})

2303
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

19
package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "braindrops",
"private": true,
"version": "0.0.0",
"type": "module",
"workspaces": [
"app",
"bin",
"drops/*",
"cms"
],
"scripts": {
"create": "node bin/createDrop",
"build:drops": "npm run build -w drops && node bin/buildManifest",
"dev:drops": "node bin/dev.js",
"dev": "npm run build:drops && npm run dev -w app",
"build": "npm run build:drops && npm run build -w app"
}
}

5
prettier.config.js Normal file
View file

@ -0,0 +1,5 @@
export default {
tabWidth: 4,
semi: false,
singleQuote: true,
}

148
shared/Btn.vue Normal file
View file

@ -0,0 +1,148 @@
<template>
<component class="btn" :is="tag">
<span v-if="arrow" class="leading-icon"></span>
<span class="text">
<span><slot /></span>
<span><slot /></span>
</span>
<span v-if="arrow" class="arrow"></span>
</component>
</template>
<script setup>
const props = defineProps({
tag: {
type: String,
default: () => 'button',
},
arrow: {
type: Boolean,
default: () => false,
},
loading: {
type: Boolean,
default: () => false,
},
})
</script>
<style lang="scss">
.btn {
--t-duration: 350ms;
--t-ease: var(--ease-out-quad);
display: inline-flex;
align-items: center;
padding: rs(6px) rs(16px);
background: var(--theme-contrast);
color: var(--ivory);
border-radius: rs(8px);
position: relative;
overflow: hidden;
cursor: pointer;
transition: color var(--t-duration) var(--t-ease);
.leading-icon {
position: absolute;
width: rs(10px);
height: 1em;
display: flex;
align-items: center;
transform: translateX(-100%);
opacity: 0;
transition:
transform var(--t-duration) var(--t-ease),
opacity var(--t-duration) var(--t-ease);
}
.text {
position: relative;
display: block;
width: 100%;
overflow: hidden;
transition: transform var(--t-duration) var(--t-ease);
span {
display: inline-block;
transition: transform var(--t-duration) var(--t-ease);
@include label;
}
span:first-child {
color: var(--ivory);
}
span:last-child {
position: absolute;
left: 0;
right: 0;
bottom: 0;
transform: translateY(105%);
color: var(--ivory);
}
}
.arrow {
transition:
transform var(--t-duration) var(--t-ease),
opacity var(--t-duration) var(--t-ease);
&:not(:only-child) {
margin-left: 0.5em;
}
}
.loading {
width: rs(10px);
height: 1em;
display: flex;
align-items: center;
svg {
width: 100%;
height: auto;
}
}
&::before {
content: '';
display: block;
position: absolute;
inset: -2px;
background: var(--theme-contrast);
clip-path: inset(100% 0 0 0);
transition: clip-path var(--t-duration) var(--t-ease);
}
&:hover,
&.active,
&.router-link-active {
color: var(--ivory);
&::before {
clip-path: inset(0);
}
.text {
span:first-child {
transform: translateY(-105%);
}
span:last-child {
transform: none;
}
}
&.text-icon {
.leading-icon {
transform: none;
opacity: 1;
}
.text {
transform: translateX(1.5em);
}
.arrow {
transform: translateX(100%);
opacity: 0;
}
}
}
&:disabled,
&.disabled {
opacity: 0.16;
pointer-events: none;
}
}
</style>

44
shared/config.js Normal file
View file

@ -0,0 +1,44 @@
const colors = {
ivory: '#FEFDFC',
'ivory-shade': '#ECEBEA',
sand: '#F7F4EF',
'sand-shade': '#B9B7B3',
charcoal: '#181818',
'charcoal-shade': '#2A2A2A',
forest: '#053F0A',
'forest-shade': '#F6FFF6',
clay: '#FA3812',
'clay-shade': '#FFEFEC',
sea: '#1253FA',
'sea-shade': '#F0F8FF',
'grey-700': '#565656',
'grey-600': '#747474',
'grey-500': '#A3A3A3',
'grey-400': '#D1D1D1',
}
const themes = {
light: {
bg: colors.ivory,
fg: colors.charcoal,
layout: colors.sand,
grey: colors['grey-600'],
shade: colors['ivory-shade'],
contrast: colors.clay,
},
dark: {
bg: colors.charcoal,
fg: colors.ivory,
layout: colors.sand,
grey: colors['grey-500'],
shade: colors['charcoal-shade'],
contrast: colors.clay,
},
}
export { colors, themes }
export default {
colors,
themes,
}

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.

Some files were not shown because too many files have changed in this diff Show more