feat(deck): add mycelial-network visualization
Animated SVG: unseen underground mycelium (the tech foundation) feeds surface "flower" locations, with faint future flowers showing new nodes sprout cheaply once the network runs. Respects prefers-reduced-motion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
adb6783a02
commit
fbbd4af50c
1 changed files with 336 additions and 0 deletions
336
src/deck/components/MyceliumNetwork.vue
Normal file
336
src/deck/components/MyceliumNetwork.vue
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* The core metaphor, as an animated SVG.
|
||||||
|
*
|
||||||
|
* Underground → the mycelium = our tech foundation / custom software
|
||||||
|
* stacks. Unseen, always working, connects everything.
|
||||||
|
* Surface → "flowers" (fruiting bodies) = physical locations
|
||||||
|
* (City + Farm) that people actually experience.
|
||||||
|
* Ghost flowers→ once the mycelium runs, new locations sprout cheaply,
|
||||||
|
* anywhere in the world.
|
||||||
|
*
|
||||||
|
* Pure SVG + CSS so it scales crisply on a projector and respects
|
||||||
|
* prefers-reduced-motion.
|
||||||
|
*/
|
||||||
|
import Icon from './Icon.vue'
|
||||||
|
import { locations } from '../data'
|
||||||
|
|
||||||
|
const GROUND = 300
|
||||||
|
|
||||||
|
// underground mycelium nodes (below the ground line)
|
||||||
|
const nodes: [number, number][] = [
|
||||||
|
[330, 340], // root under city
|
||||||
|
[670, 340], // root under farm
|
||||||
|
[150, 335], // root under ghost 1
|
||||||
|
[500, 335], // root under ghost 2
|
||||||
|
[850, 335], // root under ghost 3
|
||||||
|
[90, 430],
|
||||||
|
[250, 480],
|
||||||
|
[410, 420],
|
||||||
|
[560, 470],
|
||||||
|
[720, 430],
|
||||||
|
[900, 470],
|
||||||
|
[180, 560],
|
||||||
|
[430, 560],
|
||||||
|
[620, 560],
|
||||||
|
[810, 560],
|
||||||
|
]
|
||||||
|
|
||||||
|
// threads weaving the mycelium together (indices into `nodes`)
|
||||||
|
const edges: [number, number][] = [
|
||||||
|
[0, 7],
|
||||||
|
[0, 6],
|
||||||
|
[0, 2],
|
||||||
|
[0, 3],
|
||||||
|
[1, 8],
|
||||||
|
[1, 9],
|
||||||
|
[1, 3],
|
||||||
|
[1, 4],
|
||||||
|
[2, 5],
|
||||||
|
[2, 6],
|
||||||
|
[3, 7],
|
||||||
|
[3, 8],
|
||||||
|
[4, 9],
|
||||||
|
[4, 10],
|
||||||
|
[5, 11],
|
||||||
|
[6, 11],
|
||||||
|
[6, 12],
|
||||||
|
[7, 12],
|
||||||
|
[8, 13],
|
||||||
|
[9, 13],
|
||||||
|
[9, 14],
|
||||||
|
[10, 14],
|
||||||
|
[3, 0],
|
||||||
|
[3, 1],
|
||||||
|
]
|
||||||
|
|
||||||
|
// organic curved path between two nodes
|
||||||
|
function thread([a, b]: [number, number], i: number) {
|
||||||
|
const [ax, ay] = nodes[a]
|
||||||
|
const [bx, by] = nodes[b]
|
||||||
|
const mx = (ax + bx) / 2 + (i % 2 ? 24 : -24)
|
||||||
|
const my = (ay + by) / 2 + (i % 3 ? 18 : -14)
|
||||||
|
return `M ${ax} ${ay} Q ${mx} ${my} ${bx} ${by}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// surface flowers: the two real nodes + three faint "future" ones
|
||||||
|
const flowers = [
|
||||||
|
{ x: 330, kind: 'real', color: 'var(--color-pine-400)', name: locations.city.name, tag: 'City node' },
|
||||||
|
{ x: 670, kind: 'real', color: 'var(--color-clay-400)', name: locations.farm.name, tag: 'Farm node' },
|
||||||
|
{ x: 150, kind: 'ghost' },
|
||||||
|
{ x: 500, kind: 'ghost' },
|
||||||
|
{ x: 850, kind: 'ghost' },
|
||||||
|
] as const
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="myc w-full">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 1000 600"
|
||||||
|
class="mx-auto h-auto w-full max-h-[50vh]"
|
||||||
|
role="img"
|
||||||
|
aria-label="Mycelial network diagram"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="skyGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="hsl(160 40% 10%)" stop-opacity="0" />
|
||||||
|
<stop offset="100%" stop-color="hsl(160 45% 12%)" stop-opacity="0.5" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="soilGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="hsl(28 40% 10%)" stop-opacity="0.55" />
|
||||||
|
<stop offset="100%" stop-color="hsl(28 45% 6%)" stop-opacity="0.15" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="soft" x="-40%" y="-40%" width="180%" height="180%">
|
||||||
|
<feGaussianBlur stdDeviation="4" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- soil band -->
|
||||||
|
<rect x="0" :y="GROUND" width="1000" :height="600 - GROUND" fill="url(#soilGrad)" />
|
||||||
|
<rect x="0" y="0" width="1000" :height="GROUND" fill="url(#skyGrad)" />
|
||||||
|
<line
|
||||||
|
x1="0"
|
||||||
|
:y1="GROUND"
|
||||||
|
x2="1000"
|
||||||
|
:y2="GROUND"
|
||||||
|
stroke="var(--color-pine-500)"
|
||||||
|
stroke-opacity="0.35"
|
||||||
|
stroke-dasharray="2 6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- mycelium threads (glow underlay + crisp line + travelling pulse) -->
|
||||||
|
<g fill="none" stroke-linecap="round">
|
||||||
|
<path
|
||||||
|
v-for="(e, i) in edges"
|
||||||
|
:key="`glow-${i}`"
|
||||||
|
:d="thread(e, i)"
|
||||||
|
stroke="var(--color-pine-500)"
|
||||||
|
stroke-opacity="0.18"
|
||||||
|
stroke-width="6"
|
||||||
|
filter="url(#soft)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-for="(e, i) in edges"
|
||||||
|
:key="`line-${i}`"
|
||||||
|
:d="thread(e, i)"
|
||||||
|
stroke="var(--color-pine-400)"
|
||||||
|
stroke-opacity="0.5"
|
||||||
|
stroke-width="1.4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-for="(e, i) in edges"
|
||||||
|
:key="`pulse-${i}`"
|
||||||
|
class="myc-pulse"
|
||||||
|
:style="{ animationDelay: `${(i % 6) * 0.5}s` }"
|
||||||
|
:d="thread(e, i)"
|
||||||
|
stroke="var(--color-amber-300)"
|
||||||
|
stroke-width="2.2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- mycelium nodes -->
|
||||||
|
<g>
|
||||||
|
<circle
|
||||||
|
v-for="(n, i) in nodes"
|
||||||
|
:key="`node-${i}`"
|
||||||
|
class="myc-node"
|
||||||
|
:style="{ animationDelay: `${(i % 5) * 0.6}s` }"
|
||||||
|
:cx="n[0]"
|
||||||
|
:cy="n[1]"
|
||||||
|
r="4"
|
||||||
|
fill="var(--color-pine-300)"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- surface flowers -->
|
||||||
|
<g v-for="(f, i) in flowers" :key="`flower-${i}`">
|
||||||
|
<!-- stem -->
|
||||||
|
<path
|
||||||
|
:d="`M ${f.x} ${GROUND} C ${f.x - 6} ${GROUND - 26}, ${f.x + 6} ${GROUND - 44}, ${f.x} ${GROUND - 70}`"
|
||||||
|
fill="none"
|
||||||
|
:stroke="f.kind === 'real' ? f.color : 'var(--color-sand-dim)'"
|
||||||
|
:stroke-opacity="f.kind === 'real' ? 0.9 : 0.35"
|
||||||
|
:stroke-dasharray="f.kind === 'ghost' ? '3 5' : '0'"
|
||||||
|
stroke-width="5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<!-- cap -->
|
||||||
|
<template v-if="f.kind === 'real'">
|
||||||
|
<path
|
||||||
|
:d="`M ${f.x - 42} ${GROUND - 70} Q ${f.x} ${GROUND - 116} ${f.x + 42} ${GROUND - 70} Q ${f.x} ${GROUND - 82} ${f.x - 42} ${GROUND - 70} Z`"
|
||||||
|
:fill="f.color"
|
||||||
|
class="myc-cap"
|
||||||
|
:style="{ animationDelay: `${i * 0.4}s` }"
|
||||||
|
/>
|
||||||
|
<circle :cx="f.x - 16" :cy="GROUND - 88" r="3" fill="hsl(0 0% 100% / 0.55)" />
|
||||||
|
<circle :cx="f.x + 12" :cy="GROUND - 92" r="2.4" fill="hsl(0 0% 100% / 0.45)" />
|
||||||
|
<text
|
||||||
|
:x="f.x"
|
||||||
|
:y="GROUND - 132"
|
||||||
|
text-anchor="middle"
|
||||||
|
class="myc-label-name"
|
||||||
|
:fill="f.color"
|
||||||
|
>
|
||||||
|
{{ f.name }}
|
||||||
|
</text>
|
||||||
|
<text :x="f.x" :y="GROUND - 152" text-anchor="middle" class="myc-label-tag">
|
||||||
|
{{ f.tag?.toUpperCase() }}
|
||||||
|
</text>
|
||||||
|
</template>
|
||||||
|
<!-- ghost future flower -->
|
||||||
|
<template v-else>
|
||||||
|
<path
|
||||||
|
:d="`M ${f.x - 30} ${GROUND - 70} Q ${f.x} ${GROUND - 104} ${f.x + 30} ${GROUND - 70} Q ${f.x} ${GROUND - 80} ${f.x - 30} ${GROUND - 70} Z`"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-sand-dim)"
|
||||||
|
stroke-opacity="0.35"
|
||||||
|
stroke-dasharray="3 5"
|
||||||
|
stroke-width="1.6"
|
||||||
|
/>
|
||||||
|
<g class="myc-plus" :style="{ animationDelay: `${i * 0.7}s` }">
|
||||||
|
<circle :cx="f.x" :cy="GROUND - 86" r="9" fill="hsl(160 24% 9%)" stroke="var(--color-amber-400)" stroke-opacity="0.6" />
|
||||||
|
<path
|
||||||
|
:d="`M ${f.x - 4} ${GROUND - 86} h 8 M ${f.x} ${GROUND - 90} v 8`"
|
||||||
|
stroke="var(--color-amber-400)"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</template>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- captions -->
|
||||||
|
<text x="500" y="34" text-anchor="middle" class="myc-zone">ABOVE GROUND · WHAT PEOPLE EXPERIENCE</text>
|
||||||
|
<text x="500" y="588" text-anchor="middle" class="myc-zone">UNDERGROUND · THE TECH FOUNDATION THEY NEVER SEE</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-4 text-sm sm:grid-cols-2">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="mt-0.5 text-pine-300"><Icon name="Layers" class="size-5" /></span>
|
||||||
|
<p class="text-sand-dim">
|
||||||
|
<span class="font-medium text-sand">The mycelium is the product.</span>
|
||||||
|
Custom software stacks and open hardware run out of sight — members feel it, they don’t see it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="mt-0.5 text-amber-300"><Icon name="Sparkles" class="size-5" /></span>
|
||||||
|
<p class="text-sand-dim">
|
||||||
|
<span class="font-medium text-sand">Flowers are cheap to grow.</span>
|
||||||
|
Once the network is alive, each new location is just another fruiting body on proven infrastructure.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.myc-label-name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.myc-label-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
fill: var(--color-sand-dim);
|
||||||
|
}
|
||||||
|
.myc-zone {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.24em;
|
||||||
|
fill: var(--color-sand-dim);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myc-pulse {
|
||||||
|
stroke-dasharray: 6 220;
|
||||||
|
animation: myc-flow 3.2s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes myc-flow {
|
||||||
|
from {
|
||||||
|
stroke-dashoffset: 226;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.myc-node {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: myc-breathe 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes myc-breathe {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.myc-cap {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
animation: myc-sway 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes myc-sway {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(-1.5deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(1.5deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.myc-plus {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: myc-pop 3.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes myc-pop {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.myc-pulse,
|
||||||
|
.myc-node,
|
||||||
|
.myc-cap,
|
||||||
|
.myc-plus {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.myc-pulse {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue