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:
Patrick Mulligan 2026-07-28 18:21:17 +02:00
commit fbbd4af50c

View 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 dont 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>