initial-commit
This commit is contained in:
commit
f24db846cf
126 changed files with 11179 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
24
|
||||
5
README.md
Normal file
5
README.md
Normal 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
13
app/index.html
Normal 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
25
app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
app/public/drops/flowmap/assets/hanken-grotesk-BB3emo1n.woff2
Normal file
BIN
app/public/drops/flowmap/assets/hanken-grotesk-BB3emo1n.woff2
Normal file
Binary file not shown.
BIN
app/public/drops/flowmap/assets/hanken-grotesk-NweiADIQ.woff
Normal file
BIN
app/public/drops/flowmap/assets/hanken-grotesk-NweiADIQ.woff
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
4573
app/public/drops/flowmap/assets/index-BgOfTPdu.js
Normal file
4573
app/public/drops/flowmap/assets/index-BgOfTPdu.js
Normal file
File diff suppressed because one or more lines are too long
1
app/public/drops/flowmap/assets/index-Ct_TWNmM.css
Normal file
1
app/public/drops/flowmap/assets/index-Ct_TWNmM.css
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
app/public/drops/flowmap/favicon.png
Normal file
BIN
app/public/drops/flowmap/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
14
app/public/drops/flowmap/index.html
Normal file
14
app/public/drops/flowmap/index.html
Normal 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>
|
||||
9
app/public/drops/flowmap/meta.json
Normal file
9
app/public/drops/flowmap/meta.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "flowmap",
|
||||
"title": "Flowmap Test",
|
||||
"description": "An interactive flowmap visualization experiment and more",
|
||||
"date": "2026-07-16",
|
||||
"tags": [
|
||||
"visualization"
|
||||
]
|
||||
}
|
||||
BIN
app/public/drops/testing/assets/hanken-grotesk-BB3emo1n.woff2
Normal file
BIN
app/public/drops/testing/assets/hanken-grotesk-BB3emo1n.woff2
Normal file
Binary file not shown.
BIN
app/public/drops/testing/assets/hanken-grotesk-NweiADIQ.woff
Normal file
BIN
app/public/drops/testing/assets/hanken-grotesk-NweiADIQ.woff
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1
app/public/drops/testing/assets/index-BxlameFm.js
Normal file
1
app/public/drops/testing/assets/index-BxlameFm.js
Normal file
File diff suppressed because one or more lines are too long
1
app/public/drops/testing/assets/index-DAZLouSi.css
Normal file
1
app/public/drops/testing/assets/index-DAZLouSi.css
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
app/public/drops/testing/favicon.png
Normal file
BIN
app/public/drops/testing/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
14
app/public/drops/testing/index.html
Normal file
14
app/public/drops/testing/index.html
Normal 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>
|
||||
14
app/public/drops/testing/meta.json
Normal file
14
app/public/drops/testing/meta.json
Normal 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
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
4
app/public/manifest.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[
|
||||
"flowmap",
|
||||
"testing"
|
||||
]
|
||||
102
app/src/App.vue
Normal file
102
app/src/App.vue
Normal 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>
|
||||
152
app/src/components/Panel.vue
Normal file
152
app/src/components/Panel.vue
Normal 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
28
app/src/libs/math.js
Normal 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
6
app/src/main.js
Normal 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
0
app/src/styles/main.scss
Normal file
13
app/vite.config.js
Normal file
13
app/vite.config.js
Normal 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
17
bin/buildManifest.js
Normal 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
176
bin/createDrop.js
Normal 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
72
bin/dev.js
Normal 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
13
cms/index.html
Normal 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
25
cms/package.json
Normal 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
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
17
cms/src/App.vue
Normal 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>
|
||||
356
cms/src/components/MetaEditor.vue
Normal file
356
cms/src/components/MetaEditor.vue
Normal 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
28
cms/src/libs/math.js
Normal 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
|
||||
}
|
||||
97
cms/src/libs/vite-plugin-meta-api.js
Normal file
97
cms/src/libs/vite-plugin-meta-api.js
Normal 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
6
cms/src/main.js
Normal 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
0
cms/src/styles/main.scss
Normal file
46
cms/vite.config.js
Normal file
46
cms/vite.config.js
Normal 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
13
drops/flowmap/index.html
Normal 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>
|
||||
27
drops/flowmap/package.json
Normal file
27
drops/flowmap/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
drops/flowmap/public/favicon.png
Normal file
BIN
drops/flowmap/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
9
drops/flowmap/public/meta.json
Normal file
9
drops/flowmap/public/meta.json
Normal 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
17
drops/flowmap/src/App.vue
Normal 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>
|
||||
144
drops/flowmap/src/components/ArrowField.vue
Normal file
144
drops/flowmap/src/components/ArrowField.vue
Normal 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>
|
||||
152
drops/flowmap/src/components/Btn.vue
Normal file
152
drops/flowmap/src/components/Btn.vue
Normal 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>
|
||||
26
drops/flowmap/src/components/Flowmap.vue
Normal file
26
drops/flowmap/src/components/Flowmap.vue
Normal 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>
|
||||
77
drops/flowmap/src/components/WebglCanvas.vue
Normal file
77
drops/flowmap/src/components/WebglCanvas.vue
Normal 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>
|
||||
25
drops/flowmap/src/libs/glsl/arrows/fragment.glsl
Normal file
25
drops/flowmap/src/libs/glsl/arrows/fragment.glsl
Normal 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);
|
||||
}
|
||||
53
drops/flowmap/src/libs/glsl/arrows/vertex.glsl
Normal file
53
drops/flowmap/src/libs/glsl/arrows/vertex.glsl
Normal 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;
|
||||
}
|
||||
786
drops/flowmap/src/libs/glsl/fluid/fluidSimulation.js
Normal file
786
drops/flowmap/src/libs/glsl/fluid/fluidSimulation.js
Normal 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
|
||||
}
|
||||
}
|
||||
26
drops/flowmap/src/libs/glsl/fluid/program.js
Normal file
26
drops/flowmap/src/libs/glsl/fluid/program.js
Normal 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)
|
||||
}
|
||||
}
|
||||
57
drops/flowmap/src/libs/glsl/noise/noise.frag
Normal file
57
drops/flowmap/src/libs/glsl/noise/noise.frag
Normal 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;
|
||||
}
|
||||
7
drops/flowmap/src/libs/glsl/noise/noise.vert
Normal file
7
drops/flowmap/src/libs/glsl/noise/noise.vert
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
varying vec2 v_uv;
|
||||
|
||||
void main() {
|
||||
v_uv = uv;
|
||||
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
61
drops/flowmap/src/libs/glsl/simplex.glsl
Normal file
61
drops/flowmap/src/libs/glsl/simplex.glsl
Normal 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));
|
||||
}
|
||||
20
drops/flowmap/src/libs/math.js
Normal file
20
drops/flowmap/src/libs/math.js
Normal 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;
|
||||
}
|
||||
46
drops/flowmap/src/libs/three/createArrowGeometry.js
Normal file
46
drops/flowmap/src/libs/three/createArrowGeometry.js
Normal 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
|
||||
}
|
||||
6
drops/flowmap/src/main.js
Normal file
6
drops/flowmap/src/main.js
Normal 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");
|
||||
1
drops/flowmap/src/styles/main.scss
Normal file
1
drops/flowmap/src/styles/main.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
/* Drop specific styles */
|
||||
23
drops/flowmap/vite.config.js
Normal file
23
drops/flowmap/vite.config.js
Normal 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
13
drops/testing/index.html
Normal 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>
|
||||
21
drops/testing/package.json
Normal file
21
drops/testing/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
drops/testing/public/favicon.png
Normal file
BIN
drops/testing/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
14
drops/testing/public/meta.json
Normal file
14
drops/testing/public/meta.json
Normal 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
17
drops/testing/src/App.vue
Normal 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>
|
||||
152
drops/testing/src/components/Btn.vue
Normal file
152
drops/testing/src/components/Btn.vue
Normal 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>
|
||||
32
drops/testing/src/components/Testing.vue
Normal file
32
drops/testing/src/components/Testing.vue
Normal 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>
|
||||
20
drops/testing/src/libs/math.js
Normal file
20
drops/testing/src/libs/math.js
Normal 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/testing/src/main.js
Normal file
6
drops/testing/src/main.js
Normal 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')
|
||||
1
drops/testing/src/styles/main.scss
Normal file
1
drops/testing/src/styles/main.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
/* Drop specific styles */
|
||||
50
drops/testing/vite.config.js
Normal file
50
drops/testing/vite.config.js
Normal 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
2303
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
19
package.json
Normal file
19
package.json
Normal 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
5
prettier.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default {
|
||||
tabWidth: 4,
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
}
|
||||
148
shared/Btn.vue
Normal file
148
shared/Btn.vue
Normal 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
44
shared/config.js
Normal 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,
|
||||
}
|
||||
BIN
shared/fonts/hanken-grotesk-italic.woff
Normal file
BIN
shared/fonts/hanken-grotesk-italic.woff
Normal file
Binary file not shown.
BIN
shared/fonts/hanken-grotesk-italic.woff2
Normal file
BIN
shared/fonts/hanken-grotesk-italic.woff2
Normal file
Binary file not shown.
BIN
shared/fonts/hanken-grotesk-semibold.woff
Normal file
BIN
shared/fonts/hanken-grotesk-semibold.woff
Normal file
Binary file not shown.
BIN
shared/fonts/hanken-grotesk-semibold.woff2
Normal file
BIN
shared/fonts/hanken-grotesk-semibold.woff2
Normal file
Binary file not shown.
BIN
shared/fonts/hanken-grotesk.woff
Normal file
BIN
shared/fonts/hanken-grotesk.woff
Normal file
Binary file not shown.
BIN
shared/fonts/hanken-grotesk.woff2
Normal file
BIN
shared/fonts/hanken-grotesk.woff2
Normal file
Binary file not shown.
BIN
shared/fonts/instrument-serif-italic.woff
Normal file
BIN
shared/fonts/instrument-serif-italic.woff
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue