diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..247c67f
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,10 @@
+node_modules
+.next
+.git
+.gitignore
+README.md
+Dockerfile
+docker-compose.yml
+.dockerignore
+.env*.local
+*.log
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..52de86e
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,44 @@
+# Build stage
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+
+# Copy package files
+COPY package*.json ./
+
+# Install dependencies
+RUN npm ci
+
+# Copy source code
+COPY . .
+
+# Build the application
+RUN npm run build
+
+# Production stage
+FROM node:20-alpine AS runner
+
+WORKDIR /app
+
+ENV NODE_ENV=production
+
+# Create non-root user
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+
+# Copy built application
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/.next/standalone ./
+COPY --from=builder /app/.next/static ./.next/static
+
+# Set ownership
+RUN chown -R nextjs:nodejs /app
+
+USER nextjs
+
+EXPOSE 3000
+
+ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
+
+CMD ["node", "server.js"]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..d066707
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,22 @@
+services:
+ conviction-voting-demo:
+ build: .
+ container_name: conviction-voting-demo
+ restart: unless-stopped
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.conviction.rule=Host(`conviction.jeffemmett.com`)"
+ - "traefik.http.routers.conviction.entrypoints=web"
+ - "traefik.http.services.conviction.loadbalancer.server.port=3000"
+ networks:
+ - traefik-public
+ healthcheck:
+ test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+
+networks:
+ traefik-public:
+ external: true
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..225e495 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ output: 'standalone',
};
export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
index 256d897..accff10 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,13 @@
"name": "conviction-voting-demo",
"version": "0.1.0",
"dependencies": {
+ "@types/d3": "^7.4.3",
+ "d3": "^7.9.0",
+ "framer-motion": "^12.23.26",
"next": "16.1.1",
"react": "19.2.3",
- "react-dom": "19.2.3"
+ "react-dom": "19.2.3",
+ "zustand": "^5.0.9"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -1524,6 +1528,259 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/d3": {
+ "version": "7.4.3",
+ "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
+ "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/d3-axis": "*",
+ "@types/d3-brush": "*",
+ "@types/d3-chord": "*",
+ "@types/d3-color": "*",
+ "@types/d3-contour": "*",
+ "@types/d3-delaunay": "*",
+ "@types/d3-dispatch": "*",
+ "@types/d3-drag": "*",
+ "@types/d3-dsv": "*",
+ "@types/d3-ease": "*",
+ "@types/d3-fetch": "*",
+ "@types/d3-force": "*",
+ "@types/d3-format": "*",
+ "@types/d3-geo": "*",
+ "@types/d3-hierarchy": "*",
+ "@types/d3-interpolate": "*",
+ "@types/d3-path": "*",
+ "@types/d3-polygon": "*",
+ "@types/d3-quadtree": "*",
+ "@types/d3-random": "*",
+ "@types/d3-scale": "*",
+ "@types/d3-scale-chromatic": "*",
+ "@types/d3-selection": "*",
+ "@types/d3-shape": "*",
+ "@types/d3-time": "*",
+ "@types/d3-time-format": "*",
+ "@types/d3-timer": "*",
+ "@types/d3-transition": "*",
+ "@types/d3-zoom": "*"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-axis": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
+ "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-brush": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
+ "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-chord": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
+ "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-contour": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
+ "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-dispatch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
+ "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-dsv": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
+ "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-fetch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
+ "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-dsv": "*"
+ }
+ },
+ "node_modules/@types/d3-force": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+ "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-format": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
+ "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-geo": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+ "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-hierarchy": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
+ "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-polygon": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
+ "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-quadtree": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
+ "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-random": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
+ "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
+ "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-time-format": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
+ "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1531,6 +1788,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1559,7 +1822,7 @@
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -2596,6 +2859,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/commander": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2629,9 +2901,410 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
+ "node_modules/d3": {
+ "version": "7.9.0",
+ "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
+ "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "3",
+ "d3-axis": "3",
+ "d3-brush": "3",
+ "d3-chord": "3",
+ "d3-color": "3",
+ "d3-contour": "4",
+ "d3-delaunay": "6",
+ "d3-dispatch": "3",
+ "d3-drag": "3",
+ "d3-dsv": "3",
+ "d3-ease": "3",
+ "d3-fetch": "3",
+ "d3-force": "3",
+ "d3-format": "3",
+ "d3-geo": "3",
+ "d3-hierarchy": "3",
+ "d3-interpolate": "3",
+ "d3-path": "3",
+ "d3-polygon": "3",
+ "d3-quadtree": "3",
+ "d3-random": "3",
+ "d3-scale": "4",
+ "d3-scale-chromatic": "3",
+ "d3-selection": "3",
+ "d3-shape": "3",
+ "d3-time": "3",
+ "d3-time-format": "4",
+ "d3-timer": "3",
+ "d3-transition": "3",
+ "d3-zoom": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-axis": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
+ "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-brush": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
+ "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "3",
+ "d3-transition": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-chord": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
+ "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-contour": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
+ "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
+ "license": "ISC",
+ "dependencies": {
+ "delaunator": "5"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dsv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
+ "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
+ "license": "ISC",
+ "dependencies": {
+ "commander": "7",
+ "iconv-lite": "0.6",
+ "rw": "1"
+ },
+ "bin": {
+ "csv2json": "bin/dsv2json.js",
+ "csv2tsv": "bin/dsv2dsv.js",
+ "dsv2dsv": "bin/dsv2dsv.js",
+ "dsv2json": "bin/dsv2json.js",
+ "json2csv": "bin/json2dsv.js",
+ "json2dsv": "bin/json2dsv.js",
+ "json2tsv": "bin/json2dsv.js",
+ "tsv2csv": "bin/dsv2dsv.js",
+ "tsv2json": "bin/dsv2json.js"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-fetch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
+ "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dsv": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-force": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
+ "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-quadtree": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+ "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-geo": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
+ "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.5.0 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-hierarchy": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
+ "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-polygon": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
+ "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-quadtree": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+ "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-random": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
+ "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-interpolate": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -2754,6 +3427,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/delaunator": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
+ "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
+ "license": "ISC",
+ "dependencies": {
+ "robust-predicates": "^3.0.2"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3585,6 +4267,33 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/framer-motion": {
+ "version": "12.23.26",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
+ "integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.23.23",
+ "motion-utils": "^12.23.6",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -3890,6 +4599,18 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3942,6 +4663,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -4900,6 +5630,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/motion-dom": {
+ "version": "12.23.23",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
+ "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.23.6"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.23.6",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
+ "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5502,6 +6247,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/robust-predicates": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
+ "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
+ "license": "Unlicense"
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -5526,6 +6277,12 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/rw": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/safe-array-concat": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@@ -5581,6 +6338,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -6533,6 +7296,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
+ "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index a2c3b09..d1e1915 100644
--- a/package.json
+++ b/package.json
@@ -9,9 +9,13 @@
"lint": "eslint"
},
"dependencies": {
+ "@types/d3": "^7.4.3",
+ "d3": "^7.9.0",
+ "framer-motion": "^12.23.26",
"next": "16.1.1",
"react": "19.2.3",
- "react-dom": "19.2.3"
+ "react-dom": "19.2.3",
+ "zustand": "^5.0.9"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index f7fa87e..a197155 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -13,8 +13,9 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: "Conviction Voting Demo",
+ description: "Interactive demonstration of conviction voting dynamics - continuous charge up & discharge of collective preferences",
+ keywords: ["conviction voting", "governance", "DAO", "commons", "BlockScience"],
};
export default function RootLayout({
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 295f8fd..579e10c 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,64 +1,210 @@
-import Image from "next/image";
+'use client';
+
+import { motion } from 'framer-motion';
+import { useSimulationStore, AppMode } from '@/stores/simulation';
+import {
+ ParticipantCard,
+ ConvictionChart,
+ TriggerProgress,
+ ControlPanel,
+ NarrativePanel,
+} from '@/components';
export default function Home() {
+ const mode = useSimulationStore((s) => s.mode);
+ const setMode = useSimulationStore((s) => s.setMode);
+ const participants = useSimulationStore((s) => s.participants);
+ const narrativeStep = useSimulationStore((s) => s.narrativeStep);
+ const narrativeSteps = useSimulationStore((s) => s.narrativeSteps);
+
+ const currentStep = narrativeSteps[narrativeStep];
+ const highlight = currentStep?.highlight;
+
return (
-
-
-
-
);
diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx
new file mode 100644
index 0000000..090be98
--- /dev/null
+++ b/src/components/ControlPanel.tsx
@@ -0,0 +1,185 @@
+'use client';
+
+import { useSimulationStore } from '@/stores/simulation';
+import { halfLifeFromAlpha, alphaFromHalfLife } from '@/lib/conviction';
+
+export function ControlPanel() {
+ const epoch = useSimulationStore((s) => s.epoch);
+ const isRunning = useSimulationStore((s) => s.isRunning);
+ const speed = useSimulationStore((s) => s.speed);
+ const params = useSimulationStore((s) => s.params);
+
+ const start = useSimulationStore((s) => s.start);
+ const stop = useSimulationStore((s) => s.stop);
+ const tick = useSimulationStore((s) => s.tick);
+ const reset = useSimulationStore((s) => s.reset);
+ const setSpeed = useSimulationStore((s) => s.setSpeed);
+ const setParams = useSimulationStore((s) => s.setParams);
+
+ const halfLife = halfLifeFromAlpha(params.alpha);
+
+ return (
+
+
+ Simulation Controls
+
+
+ {/* Playback controls */}
+
+ {!isRunning ? (
+
+ ▶ Play
+
+ ) : (
+
+ ⏸ Pause
+
+ )}
+
+ Step
+
+
+ Reset
+
+
+
+ {/* Epoch counter */}
+
+ Epoch:
+ {epoch}
+
+
+ {/* Speed control */}
+
+
+ Speed: {(1000 / speed).toFixed(1)}x
+
+
setSpeed(2100 - Number(e.target.value))}
+ className="w-full"
+ />
+
+ Slow
+ Fast
+
+
+
+ {/* Alpha / Half-life control */}
+
+
+ Half-life: {halfLife.toFixed(1)} epochs (α = {params.alpha.toFixed(3)})
+
+
{
+ const newHalfLife = Number(e.target.value);
+ const newAlpha = alphaFromHalfLife(newHalfLife);
+ setParams({ alpha: newAlpha });
+ }}
+ className="w-full"
+ />
+
+ Fast decay
+ Slow decay
+
+
+
+ {/* Advanced params (collapsible) */}
+
+
+ Advanced Parameters
+
+
+
+
+ {/* Formula reference */}
+
+
+ Conviction: yt+1 = α × yt + x
+
+
+ Max: ymax = x / (1 - α)
+
+
+
+ );
+}
diff --git a/src/components/ConvictionChart.tsx b/src/components/ConvictionChart.tsx
new file mode 100644
index 0000000..54a8841
--- /dev/null
+++ b/src/components/ConvictionChart.tsx
@@ -0,0 +1,213 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import * as d3 from 'd3';
+import { useSimulationStore } from '@/stores/simulation';
+
+interface ConvictionChartProps {
+ height?: number;
+ showTrigger?: boolean;
+}
+
+export function ConvictionChart({ height = 300, showTrigger = true }: ConvictionChartProps) {
+ const svgRef = useRef(null);
+ const containerRef = useRef(null);
+
+ const participants = useSimulationStore((s) => s.participants);
+ const proposals = useSimulationStore((s) => s.proposals);
+ const epochHistory = useSimulationStore((s) => s.epochHistory);
+
+ useEffect(() => {
+ if (!svgRef.current || !containerRef.current) return;
+
+ const svg = d3.select(svgRef.current);
+ const containerWidth = containerRef.current.clientWidth;
+ const width = containerWidth;
+
+ const margin = { top: 20, right: 80, bottom: 40, left: 60 };
+ const innerWidth = width - margin.left - margin.right;
+ const innerHeight = height - margin.top - margin.bottom;
+
+ // Clear previous content
+ svg.selectAll('*').remove();
+
+ // Get the current proposal's trigger
+ const proposal = proposals[0];
+ const trigger = proposal?.trigger || Infinity;
+
+ // Calculate max conviction for y-axis
+ const allConvictions = participants.flatMap((p) => p.convictionHistory);
+ const totalConvictions = proposal?.convictionHistory || [0];
+ const maxY = Math.max(
+ d3.max(allConvictions) || 0,
+ d3.max(totalConvictions) || 0,
+ showTrigger && trigger !== Infinity ? trigger * 1.1 : 0,
+ 100
+ );
+
+ // Scales
+ const xScale = d3
+ .scaleLinear()
+ .domain([0, Math.max(epochHistory.length - 1, 10)])
+ .range([0, innerWidth]);
+
+ const yScale = d3.scaleLinear().domain([0, maxY]).range([innerHeight, 0]);
+
+ // Create main group
+ const g = svg
+ .append('g')
+ .attr('transform', `translate(${margin.left},${margin.top})`);
+
+ // Grid lines
+ g.append('g')
+ .attr('class', 'grid')
+ .attr('opacity', 0.1)
+ .call(
+ d3
+ .axisLeft(yScale)
+ .tickSize(-innerWidth)
+ .tickFormat(() => '')
+ );
+
+ // Trigger line (if showing)
+ if (showTrigger && trigger !== Infinity) {
+ g.append('line')
+ .attr('x1', 0)
+ .attr('x2', innerWidth)
+ .attr('y1', yScale(trigger))
+ .attr('y2', yScale(trigger))
+ .attr('stroke', '#ef4444')
+ .attr('stroke-width', 2)
+ .attr('stroke-dasharray', '8,4');
+
+ g.append('text')
+ .attr('x', innerWidth + 5)
+ .attr('y', yScale(trigger))
+ .attr('dy', '0.35em')
+ .attr('fill', '#ef4444')
+ .attr('font-size', '12px')
+ .attr('font-weight', 'bold')
+ .text('TRIGGER');
+ }
+
+ // Line generator
+ const line = d3
+ .line()
+ .x((_, i) => xScale(i))
+ .y((d) => yScale(d))
+ .curve(d3.curveMonotoneX);
+
+ // Area generator for individual convictions
+ const area = d3
+ .area()
+ .x((_, i) => xScale(i))
+ .y0(innerHeight)
+ .y1((d) => yScale(d))
+ .curve(d3.curveMonotoneX);
+
+ // Draw individual participant conviction areas (stacked feel)
+ participants.forEach((participant, index) => {
+ const data = participant.convictionHistory;
+
+ // Area fill
+ g.append('path')
+ .datum(data)
+ .attr('fill', participant.color)
+ .attr('fill-opacity', 0.2)
+ .attr('d', area);
+
+ // Line
+ g.append('path')
+ .datum(data)
+ .attr('fill', 'none')
+ .attr('stroke', participant.color)
+ .attr('stroke-width', 2)
+ .attr('d', line);
+
+ // Current value dot
+ if (data.length > 0) {
+ g.append('circle')
+ .attr('cx', xScale(data.length - 1))
+ .attr('cy', yScale(data[data.length - 1]))
+ .attr('r', 5)
+ .attr('fill', participant.color)
+ .attr('stroke', 'white')
+ .attr('stroke-width', 2);
+ }
+ });
+
+ // Draw total conviction line (bold)
+ if (proposal && totalConvictions.length > 0) {
+ g.append('path')
+ .datum(totalConvictions)
+ .attr('fill', 'none')
+ .attr('stroke', '#1f2937')
+ .attr('stroke-width', 3)
+ .attr('d', line);
+
+ // Total label
+ g.append('text')
+ .attr('x', innerWidth + 5)
+ .attr('y', yScale(totalConvictions[totalConvictions.length - 1]))
+ .attr('dy', '0.35em')
+ .attr('fill', '#1f2937')
+ .attr('font-size', '12px')
+ .attr('font-weight', 'bold')
+ .text('TOTAL');
+ }
+
+ // X Axis
+ g.append('g')
+ .attr('transform', `translate(0,${innerHeight})`)
+ .call(d3.axisBottom(xScale).ticks(10))
+ .append('text')
+ .attr('x', innerWidth / 2)
+ .attr('y', 35)
+ .attr('fill', '#374151')
+ .attr('text-anchor', 'middle')
+ .text('Epoch');
+
+ // Y Axis
+ g.append('g')
+ .call(d3.axisLeft(yScale).ticks(5))
+ .append('text')
+ .attr('transform', 'rotate(-90)')
+ .attr('x', -innerHeight / 2)
+ .attr('y', -45)
+ .attr('fill', '#374151')
+ .attr('text-anchor', 'middle')
+ .text('Conviction');
+
+ // Legend
+ const legend = g
+ .append('g')
+ .attr('transform', `translate(10, 10)`);
+
+ participants.forEach((p, i) => {
+ const legendRow = legend
+ .append('g')
+ .attr('transform', `translate(0, ${i * 18})`);
+
+ legendRow
+ .append('rect')
+ .attr('width', 12)
+ .attr('height', 12)
+ .attr('fill', p.color)
+ .attr('rx', 2);
+
+ legendRow
+ .append('text')
+ .attr('x', 18)
+ .attr('y', 10)
+ .attr('font-size', '11px')
+ .attr('fill', '#374151')
+ .text(p.name);
+ });
+ }, [participants, proposals, epochHistory, height, showTrigger]);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/NarrativePanel.tsx b/src/components/NarrativePanel.tsx
new file mode 100644
index 0000000..7903b9d
--- /dev/null
+++ b/src/components/NarrativePanel.tsx
@@ -0,0 +1,104 @@
+'use client';
+
+import { motion, AnimatePresence } from 'framer-motion';
+import { useSimulationStore } from '@/stores/simulation';
+
+export function NarrativePanel() {
+ const narrativeStep = useSimulationStore((s) => s.narrativeStep);
+ const narrativeSteps = useSimulationStore((s) => s.narrativeSteps);
+ const nextNarrativeStep = useSimulationStore((s) => s.nextNarrativeStep);
+ const prevNarrativeStep = useSimulationStore((s) => s.prevNarrativeStep);
+ const resetNarrative = useSimulationStore((s) => s.resetNarrative);
+ const setMode = useSimulationStore((s) => s.setMode);
+
+ const currentStep = narrativeSteps[narrativeStep];
+ const isFirst = narrativeStep === 0;
+ const isLast = narrativeStep === narrativeSteps.length - 1;
+
+ if (!currentStep) return null;
+
+ return (
+
+ {/* Progress indicator */}
+
+ {narrativeSteps.map((_, i) => (
+
+ ))}
+
+
+ {/* Step counter */}
+
+ Step {narrativeStep + 1} of {narrativeSteps.length}
+
+
+ {/* Content */}
+
+
+
+ {currentStep.title}
+
+
+ {currentStep.description}
+
+
+
+
+ {/* Navigation */}
+
+
+ ← Back
+
+ {isLast ? (
+ setMode('simulation')}
+ className="flex-1 py-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-medium transition-colors"
+ >
+ Try Simulation Mode →
+
+ ) : (
+
+ Next →
+
+ )}
+
+
+ {/* Restart option */}
+
+ Restart from beginning
+
+
+ );
+}
diff --git a/src/components/ParticipantCard.tsx b/src/components/ParticipantCard.tsx
new file mode 100644
index 0000000..11822f0
--- /dev/null
+++ b/src/components/ParticipantCard.tsx
@@ -0,0 +1,128 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import { useSimulationStore } from '@/stores/simulation';
+import { maxConviction } from '@/lib/conviction';
+
+interface ParticipantCardProps {
+ participantId: string;
+ highlight?: boolean;
+}
+
+export function ParticipantCard({ participantId, highlight }: ParticipantCardProps) {
+ const participant = useSimulationStore((s) =>
+ s.participants.find((p) => p.id === participantId)
+ );
+ const params = useSimulationStore((s) => s.params);
+ const toggleStake = useSimulationStore((s) => s.toggleStake);
+ const isRunning = useSimulationStore((s) => s.isRunning);
+
+ if (!participant) return null;
+
+ const { name, color, holdings, stakedTokens, conviction } = participant;
+ const isStaking = stakedTokens > 0;
+ const maxConv = maxConviction(stakedTokens, params.alpha);
+ const convictionPercent = maxConv > 0 ? (conviction / maxConv) * 100 : 0;
+ const chargeLevel = Math.min(convictionPercent, 100);
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Conviction Bar (Capacitor visualization) */}
+
+ {/* Max conviction indicator */}
+ {isStaking && (
+
+ )}
+
+ {/* Current conviction fill */}
+
+
+ {/* Conviction value */}
+
+
+ {conviction.toFixed(0)}
+
+
+
+ {/* Charging animation */}
+ {isStaking && isRunning && (
+
+ )}
+
+
+ {/* Stake button */}
+ toggleStake(participantId)}
+ className={`
+ w-full py-2 px-4 rounded-lg font-medium transition-all
+ ${
+ isStaking
+ ? 'bg-red-100 text-red-700 hover:bg-red-200'
+ : 'bg-green-100 text-green-700 hover:bg-green-200'
+ }
+ `}
+ >
+ {isStaking ? `Unstake ${stakedTokens}` : `Stake ${holdings}`}
+
+
+ {/* Status indicator */}
+
+ {isStaking ? (
+
+ {chargeLevel.toFixed(0)}% charged
+ {chargeLevel < 99 && ' (charging...)'}
+
+ ) : conviction > 0 ? (
+ Discharging...
+ ) : (
+ Ready to stake
+ )}
+
+
+ );
+}
diff --git a/src/components/TriggerProgress.tsx b/src/components/TriggerProgress.tsx
new file mode 100644
index 0000000..ced4726
--- /dev/null
+++ b/src/components/TriggerProgress.tsx
@@ -0,0 +1,153 @@
+'use client';
+
+import { motion } from 'framer-motion';
+import { useSimulationStore } from '@/stores/simulation';
+import { triggerProgress } from '@/lib/conviction';
+
+interface TriggerProgressProps {
+ proposalId?: string;
+ highlight?: boolean;
+}
+
+export function TriggerProgress({ proposalId, highlight }: TriggerProgressProps) {
+ const proposals = useSimulationStore((s) => s.proposals);
+ const proposal = proposalId
+ ? proposals.find((p) => p.id === proposalId)
+ : proposals[0];
+
+ if (!proposal) return null;
+
+ const { title, fundsRequested, totalConviction, trigger, status } = proposal;
+ const progress = triggerProgress(totalConviction, trigger);
+ const progressPercent = Math.min(progress * 100, 100);
+ const isPassed = status === 'active' || status === 'completed';
+
+ return (
+
+ {/* Header */}
+
+
+
{title}
+
+ Requesting: {fundsRequested.toLocaleString()} funds
+
+
+ {isPassed && (
+
+ PASSED
+
+ )}
+
+
+ {/* Progress bar */}
+
+ {/* Trigger threshold marker */}
+
+
+ {/* Progress fill */}
+
+
+ {/* Progress label */}
+
+
+ {progressPercent.toFixed(1)}%
+
+
+
+
+ {/* Stats */}
+
+
+ Current:
+ {totalConviction.toFixed(0)}
+
+
+ Threshold:
+
+ {trigger === Infinity ? '∞' : trigger.toFixed(0)}
+
+
+
+
+ {/* Visual metaphor: Water tank */}
+
+ {/* Water level */}
+
+
+ {/* Threshold line */}
+
+
+ TRIGGER
+
+
+
+ {/* Ripple effect when filling */}
+ {!isPassed && progressPercent > 0 && (
+
+ )}
+
+
+ {/* Explanation */}
+
+ {isPassed
+ ? 'Collective conviction exceeded the threshold!'
+ : progressPercent > 80
+ ? 'Almost there! Keep the conviction growing...'
+ : progressPercent > 50
+ ? 'Past halfway! The bucket is filling up.'
+ : progressPercent > 0
+ ? 'Conviction is accumulating. Hold steady.'
+ : 'Stake tokens to start building conviction.'}
+
+
+ );
+}
diff --git a/src/components/index.ts b/src/components/index.ts
new file mode 100644
index 0000000..ccbc40e
--- /dev/null
+++ b/src/components/index.ts
@@ -0,0 +1,5 @@
+export { ParticipantCard } from './ParticipantCard';
+export { ConvictionChart } from './ConvictionChart';
+export { TriggerProgress } from './TriggerProgress';
+export { ControlPanel } from './ControlPanel';
+export { NarrativePanel } from './NarrativePanel';
diff --git a/src/lib/conviction.ts b/src/lib/conviction.ts
new file mode 100644
index 0000000..8eb7b72
--- /dev/null
+++ b/src/lib/conviction.ts
@@ -0,0 +1,392 @@
+/**
+ * Conviction Voting Math Engine
+ *
+ * Based on BlockScience's cadCAD model and formal derivations:
+ * - Deriving_Alpha.ipynb
+ * - Trigger_Function_Explanation.ipynb
+ *
+ * Core formula: y_{t+1} = α × y_t + x_t
+ * Where:
+ * y = conviction (accumulated support)
+ * x = tokens currently staked
+ * α = decay parameter ∈ (0,1)
+ */
+
+// ============================================================================
+// Core Types
+// ============================================================================
+
+export interface Participant {
+ id: string;
+ name: string;
+ color: string;
+ holdings: number; // Total tokens available
+ stakedTokens: number; // Currently staked on proposal
+ conviction: number; // Accumulated conviction
+ convictionHistory: number[];
+ stakingHistory: number[];
+}
+
+export interface Proposal {
+ id: string;
+ title: string;
+ description: string;
+ fundsRequested: number;
+ totalConviction: number;
+ trigger: number; // Threshold to pass
+ status: 'candidate' | 'active' | 'completed' | 'failed';
+ age: number;
+ convictionHistory: number[];
+}
+
+export interface ConvictionParams {
+ alpha: number; // Decay parameter (0.5-0.99)
+ beta: number; // Max share of funds per proposal (e.g., 0.2)
+ rho: number; // Trigger function scale factor
+ supply: number; // Total token supply
+ funds: number; // Available funds in pool
+ tmin: number; // Minimum age before proposal can pass
+}
+
+// ============================================================================
+// Alpha / Half-Life Conversion
+// ============================================================================
+
+/**
+ * Calculate alpha from desired half-life
+ * α = 2^(-1/T)
+ */
+export function alphaFromHalfLife(halfLifeEpochs: number): number {
+ return Math.pow(2, -1 / halfLifeEpochs);
+}
+
+/**
+ * Calculate half-life from alpha
+ * T = -log_α(2) = -ln(2) / ln(α)
+ */
+export function halfLifeFromAlpha(alpha: number): number {
+ if (alpha <= 0 || alpha >= 1) {
+ throw new Error('Alpha must be in (0, 1)');
+ }
+ return -Math.log(2) / Math.log(alpha);
+}
+
+/**
+ * Convert half-life between time units
+ * e.g., 7 days with 24 epochs/day = 168 epoch half-life
+ */
+export function scaleHalfLife(halfLifeDays: number, epochsPerDay: number): number {
+ return halfLifeDays * epochsPerDay;
+}
+
+// ============================================================================
+// Conviction Dynamics
+// ============================================================================
+
+/**
+ * Single step conviction update
+ * y_{t+1} = α × y_t + x_t
+ */
+export function updateConviction(
+ currentConviction: number,
+ stakedTokens: number,
+ alpha: number
+): number {
+ return alpha * currentConviction + stakedTokens;
+}
+
+/**
+ * Calculate maximum possible conviction for a given stake
+ * y_max = x / (1 - α)
+ *
+ * This is the equilibrium conviction when tokens are held constant indefinitely
+ */
+export function maxConviction(stakedTokens: number, alpha: number): number {
+ return stakedTokens / (1 - alpha);
+}
+
+/**
+ * Calculate conviction at time T given initial conviction and constant stake
+ * y_T = α^T × y_0 + x × (1 - α^T) / (1 - α)
+ *
+ * Uses geometric series: Σ(α^t) from t=0 to T-1 = (1 - α^T) / (1 - α)
+ */
+export function convictionAtTime(
+ initialConviction: number,
+ stakedTokens: number,
+ alpha: number,
+ epochs: number
+): number {
+ const alphaT = Math.pow(alpha, epochs);
+ const geometricSum = (1 - alphaT) / (1 - alpha);
+ return alphaT * initialConviction + stakedTokens * geometricSum;
+}
+
+/**
+ * Calculate conviction decay (when tokens are unstaked, x=0)
+ * y_T = α^T × y_0
+ */
+export function convictionDecay(
+ initialConviction: number,
+ alpha: number,
+ epochs: number
+): number {
+ return Math.pow(alpha, epochs) * initialConviction;
+}
+
+/**
+ * Time to reach a target conviction percentage (0-1) of max
+ * Solving: targetFraction = (1 - α^T)
+ * T = ln(1 - targetFraction) / ln(α)
+ */
+export function epochsToReachFraction(
+ targetFraction: number,
+ alpha: number
+): number {
+ if (targetFraction <= 0 || targetFraction >= 1) {
+ return targetFraction <= 0 ? 0 : Infinity;
+ }
+ return Math.log(1 - targetFraction) / Math.log(alpha);
+}
+
+// ============================================================================
+// Trigger Function
+// ============================================================================
+
+/**
+ * Calculate trigger threshold for a proposal
+ * y*(r) = ρS / ((1-α)(β - r/R)²)
+ *
+ * @param requested - Funds requested by proposal
+ * @param funds - Total available funds (R)
+ * @param supply - Total token supply (S)
+ * @param params - Conviction parameters (α, β, ρ)
+ */
+export function triggerThreshold(
+ requested: number,
+ funds: number,
+ supply: number,
+ params: Pick
+): number {
+ const { alpha, beta, rho } = params;
+ const share = requested / funds;
+
+ if (share >= beta) {
+ return Infinity; // Request too large
+ }
+
+ const denominator = Math.pow(beta - share, 2) * (1 - alpha);
+ return (rho * supply) / denominator;
+}
+
+/**
+ * Calculate minimum conviction required (for r=0)
+ * y*(0) = ρS / ((1-α)β²)
+ */
+export function minRequiredConviction(
+ supply: number,
+ params: Pick
+): number {
+ return triggerThreshold(0, 1, supply, params);
+}
+
+/**
+ * Calculate maximum achievable conviction
+ * y_max = S / (1-α)
+ */
+export function maxAchievableConviction(
+ supply: number,
+ alpha: number
+): number {
+ return supply / (1 - alpha);
+}
+
+/**
+ * Calculate maximum achievable request
+ * r_max = (β - √ρ) × R
+ */
+export function maxAchievableRequest(
+ funds: number,
+ params: Pick
+): number {
+ const { beta, rho } = params;
+ return (beta - Math.sqrt(rho)) * funds;
+}
+
+/**
+ * Calculate progress toward trigger (0-1, can exceed 1)
+ */
+export function triggerProgress(
+ conviction: number,
+ trigger: number
+): number {
+ if (trigger === Infinity) return 0;
+ return conviction / trigger;
+}
+
+// ============================================================================
+// Simulation Engine
+// ============================================================================
+
+export interface SimulationState {
+ epoch: number;
+ participants: Participant[];
+ proposals: Proposal[];
+ params: ConvictionParams;
+ events: SimulationEvent[];
+}
+
+export interface SimulationEvent {
+ epoch: number;
+ type: 'stake' | 'unstake' | 'proposal_passed' | 'proposal_failed' | 'participant_joined';
+ participantId?: string;
+ proposalId?: string;
+ amount?: number;
+ description: string;
+}
+
+/**
+ * Advance simulation by one epoch
+ */
+export function simulateEpoch(state: SimulationState): SimulationState {
+ const { participants, proposals, params } = state;
+ const newEpoch = state.epoch + 1;
+ const newEvents: SimulationEvent[] = [];
+
+ // Update individual convictions
+ const updatedParticipants = participants.map(p => {
+ const newConviction = updateConviction(p.conviction, p.stakedTokens, params.alpha);
+ return {
+ ...p,
+ conviction: newConviction,
+ convictionHistory: [...p.convictionHistory, newConviction],
+ stakingHistory: [...p.stakingHistory, p.stakedTokens]
+ };
+ });
+
+ // Calculate total conviction per proposal and check triggers
+ const updatedProposals = proposals.map(proposal => {
+ if (proposal.status !== 'candidate') {
+ return {
+ ...proposal,
+ age: proposal.age + 1,
+ convictionHistory: [...proposal.convictionHistory, proposal.totalConviction]
+ };
+ }
+
+ // Sum individual convictions for this proposal
+ const totalConviction = updatedParticipants.reduce(
+ (sum, p) => sum + p.conviction,
+ 0
+ );
+
+ const newAge = proposal.age + 1;
+ const trigger = triggerThreshold(
+ proposal.fundsRequested,
+ params.funds,
+ params.supply,
+ params
+ );
+
+ let newStatus: Proposal['status'] = proposal.status;
+
+ // Check if proposal passes
+ if (newAge >= params.tmin && totalConviction >= trigger) {
+ newStatus = 'active';
+ newEvents.push({
+ epoch: newEpoch,
+ type: 'proposal_passed',
+ proposalId: proposal.id,
+ description: `Proposal "${proposal.title}" passed with ${totalConviction.toFixed(0)} conviction (threshold: ${trigger.toFixed(0)})`
+ });
+ }
+
+ return {
+ ...proposal,
+ totalConviction,
+ trigger,
+ status: newStatus,
+ age: newAge,
+ convictionHistory: [...proposal.convictionHistory, totalConviction]
+ };
+ });
+
+ return {
+ epoch: newEpoch,
+ participants: updatedParticipants,
+ proposals: updatedProposals,
+ params: state.params,
+ events: [...state.events, ...newEvents]
+ };
+}
+
+/**
+ * Generate charging curve data for visualization
+ * Shows how conviction builds up over time with constant stake
+ */
+export function generateChargingCurve(
+ stakedTokens: number,
+ alpha: number,
+ epochs: number
+): { epoch: number; conviction: number; percentOfMax: number }[] {
+ const max = maxConviction(stakedTokens, alpha);
+ const data = [];
+
+ for (let t = 0; t <= epochs; t++) {
+ const conviction = convictionAtTime(0, stakedTokens, alpha, t);
+ data.push({
+ epoch: t,
+ conviction,
+ percentOfMax: (conviction / max) * 100
+ });
+ }
+
+ return data;
+}
+
+/**
+ * Generate discharge curve data for visualization
+ * Shows how conviction decays over time after unstaking
+ */
+export function generateDischargeCurve(
+ initialConviction: number,
+ alpha: number,
+ epochs: number
+): { epoch: number; conviction: number; percentOfInitial: number }[] {
+ const data = [];
+
+ for (let t = 0; t <= epochs; t++) {
+ const conviction = convictionDecay(initialConviction, alpha, t);
+ data.push({
+ epoch: t,
+ conviction,
+ percentOfInitial: (conviction / initialConviction) * 100
+ });
+ }
+
+ return data;
+}
+
+// ============================================================================
+// Default Parameters (from BlockScience 1Hive example)
+// ============================================================================
+
+export const DEFAULT_PARAMS: ConvictionParams = {
+ alpha: 0.9, // ~7 epoch half-life
+ beta: 0.2, // Max 20% of funds per proposal
+ rho: 0.0025, // Trigger scale factor
+ supply: 100000, // Total token supply
+ funds: 50000, // Available funds
+ tmin: 3 // Min 3 epochs before passing
+};
+
+export const PARTICIPANT_COLORS = [
+ '#FF6B6B', // Red
+ '#4ECDC4', // Teal
+ '#45B7D1', // Blue
+ '#96CEB4', // Green
+ '#FFEAA7', // Yellow
+ '#DDA0DD', // Plum
+ '#98D8C8', // Mint
+ '#F7DC6F', // Gold
+];
diff --git a/src/stores/simulation.ts b/src/stores/simulation.ts
new file mode 100644
index 0000000..081d549
--- /dev/null
+++ b/src/stores/simulation.ts
@@ -0,0 +1,513 @@
+import { create } from 'zustand';
+import {
+ Participant,
+ Proposal,
+ ConvictionParams,
+ SimulationEvent,
+ DEFAULT_PARAMS,
+ PARTICIPANT_COLORS,
+ updateConviction,
+ triggerThreshold,
+ alphaFromHalfLife,
+ halfLifeFromAlpha,
+} from '@/lib/conviction';
+
+// ============================================================================
+// Store Types
+// ============================================================================
+
+export type AppMode = 'simulation' | 'narrative';
+
+export interface NarrativeStep {
+ id: number;
+ title: string;
+ description: string;
+ action: () => void;
+ highlight?: string; // Element to highlight
+}
+
+interface SimulationStore {
+ // Mode
+ mode: AppMode;
+ setMode: (mode: AppMode) => void;
+
+ // Simulation state
+ epoch: number;
+ isRunning: boolean;
+ speed: number; // ms per epoch
+
+ // Entities
+ participants: Participant[];
+ proposals: Proposal[];
+ params: ConvictionParams;
+ events: SimulationEvent[];
+
+ // History for charts
+ epochHistory: number[];
+
+ // Actions
+ addParticipant: (name: string, holdings: number) => void;
+ removeParticipant: (id: string) => void;
+ setStake: (participantId: string, amount: number) => void;
+ toggleStake: (participantId: string) => void;
+
+ addProposal: (title: string, fundsRequested: number) => void;
+
+ setParams: (params: Partial) => void;
+ setHalfLife: (days: number) => void;
+
+ // Simulation controls
+ tick: () => void;
+ start: () => void;
+ stop: () => void;
+ reset: () => void;
+ setSpeed: (speed: number) => void;
+
+ // Narrative mode
+ narrativeStep: number;
+ narrativeSteps: NarrativeStep[];
+ nextNarrativeStep: () => void;
+ prevNarrativeStep: () => void;
+ resetNarrative: () => void;
+}
+
+// ============================================================================
+// Initial State
+// ============================================================================
+
+const createInitialParticipants = (): Participant[] => [
+ {
+ id: 'alice',
+ name: 'Alice',
+ color: PARTICIPANT_COLORS[0],
+ holdings: 1000,
+ stakedTokens: 0,
+ conviction: 0,
+ convictionHistory: [0],
+ stakingHistory: [0],
+ },
+ {
+ id: 'bob',
+ name: 'Bob',
+ color: PARTICIPANT_COLORS[1],
+ holdings: 800,
+ stakedTokens: 0,
+ conviction: 0,
+ convictionHistory: [0],
+ stakingHistory: [0],
+ },
+ {
+ id: 'charlie',
+ name: 'Charlie',
+ color: PARTICIPANT_COLORS[2],
+ holdings: 1200,
+ stakedTokens: 0,
+ conviction: 0,
+ convictionHistory: [0],
+ stakingHistory: [0],
+ },
+];
+
+const createInitialProposal = (params: ConvictionParams): Proposal => ({
+ id: 'proposal-1',
+ title: 'Fund the Community Garden',
+ description: 'Build a community garden for local food production',
+ fundsRequested: 5000,
+ totalConviction: 0,
+ trigger: triggerThreshold(5000, params.funds, params.supply, params),
+ status: 'candidate',
+ age: 0,
+ convictionHistory: [0],
+});
+
+// ============================================================================
+// Narrative Steps
+// ============================================================================
+
+const createNarrativeSteps = (store: SimulationStore): NarrativeStep[] => [
+ {
+ id: 0,
+ title: 'Welcome to Conviction Voting',
+ description: `Imagine a community pool of funds that needs to be allocated fairly.
+ Unlike traditional voting with fixed deadlines, conviction voting lets preferences
+ accumulate continuously over time - like water filling a bucket.`,
+ action: () => {},
+ },
+ {
+ id: 1,
+ title: 'Meet the Participants',
+ description: `Three community members - Alice, Bob, and Charlie - each hold tokens
+ that represent their voice in governance. They can stake these tokens on proposals
+ they support.`,
+ action: () => {},
+ highlight: 'participants',
+ },
+ {
+ id: 2,
+ title: 'Alice Stakes First',
+ description: `Alice believes strongly in the garden proposal. She stakes all 1000
+ of her tokens. Watch how her conviction begins to charge up like a capacitor...`,
+ action: () => {
+ store.setStake('alice', 1000);
+ store.start();
+ },
+ highlight: 'alice',
+ },
+ {
+ id: 3,
+ title: 'Conviction Grows Over Time',
+ description: `Notice how Alice's conviction doesn't jump instantly to maximum -
+ it grows asymptotically. With α=0.9, her conviction approaches 10x her staked
+ tokens (1000 / (1-0.9) = 10,000) over time.`,
+ action: () => {},
+ highlight: 'chart',
+ },
+ {
+ id: 4,
+ title: 'Bob Joins the Cause',
+ description: `Seeing Alice's commitment, Bob decides to stake 800 tokens too.
+ His conviction starts from zero and begins charging up, adding to the collective.`,
+ action: () => {
+ store.setStake('bob', 800);
+ },
+ highlight: 'bob',
+ },
+ {
+ id: 5,
+ title: 'Collective Conviction',
+ description: `The proposal tracks total conviction from all supporters.
+ Watch the collective bar approach the trigger threshold...`,
+ action: () => {},
+ highlight: 'trigger',
+ },
+ {
+ id: 6,
+ title: 'Charlie Has Doubts',
+ description: `Charlie initially supported the proposal but changed his mind.
+ What happens when someone unstakes? Let's have Bob unstake and watch the decay...`,
+ action: () => {
+ store.setStake('bob', 0);
+ },
+ highlight: 'bob',
+ },
+ {
+ id: 7,
+ title: 'Conviction Decays (The Leaky Bucket)',
+ description: `When Bob unstakes, his conviction doesn't vanish instantly - it
+ decays exponentially with the same half-life. This is the "leaky bucket" -
+ conviction slowly drains away without continued commitment.`,
+ action: () => {},
+ highlight: 'chart',
+ },
+ {
+ id: 8,
+ title: 'The Signal Processing View',
+ description: `Conviction voting is fundamentally signal processing: noisy,
+ fluctuating individual preferences are smoothed into a stable collective signal.
+ The α parameter controls how much "memory" the system has.`,
+ action: () => {
+ store.setStake('charlie', 1200);
+ store.setStake('bob', 800);
+ },
+ },
+ {
+ id: 9,
+ title: 'Crossing the Threshold',
+ description: `When collective conviction exceeds the trigger threshold, the
+ proposal passes! The threshold is higher for larger funding requests - the
+ community must show stronger, more sustained conviction for bigger asks.`,
+ action: () => {},
+ highlight: 'trigger',
+ },
+ {
+ id: 10,
+ title: 'Explore on Your Own',
+ description: `Now switch to Simulation mode to experiment freely! Try adjusting
+ the half-life, adding more participants, or creating competing proposals.`,
+ action: () => {
+ store.stop();
+ },
+ },
+];
+
+// ============================================================================
+// Store Implementation
+// ============================================================================
+
+export const useSimulationStore = create((set, get) => {
+ let intervalId: NodeJS.Timeout | null = null;
+
+ const store: SimulationStore = {
+ // Mode
+ mode: 'narrative',
+ setMode: (mode) => set({ mode }),
+
+ // Simulation state
+ epoch: 0,
+ isRunning: false,
+ speed: 500,
+
+ // Entities
+ participants: createInitialParticipants(),
+ proposals: [createInitialProposal(DEFAULT_PARAMS)],
+ params: DEFAULT_PARAMS,
+ events: [],
+
+ // History
+ epochHistory: [0],
+
+ // Participant actions
+ addParticipant: (name, holdings) => {
+ const id = `participant-${Date.now()}`;
+ const colorIndex = get().participants.length % PARTICIPANT_COLORS.length;
+ const newParticipant: Participant = {
+ id,
+ name,
+ color: PARTICIPANT_COLORS[colorIndex],
+ holdings,
+ stakedTokens: 0,
+ conviction: 0,
+ convictionHistory: [0],
+ stakingHistory: [0],
+ };
+ set((state) => ({
+ participants: [...state.participants, newParticipant],
+ events: [
+ ...state.events,
+ {
+ epoch: state.epoch,
+ type: 'participant_joined',
+ participantId: id,
+ description: `${name} joined with ${holdings} tokens`,
+ },
+ ],
+ }));
+ },
+
+ removeParticipant: (id) => {
+ set((state) => ({
+ participants: state.participants.filter((p) => p.id !== id),
+ }));
+ },
+
+ setStake: (participantId, amount) => {
+ set((state) => {
+ const participant = state.participants.find((p) => p.id === participantId);
+ if (!participant) return state;
+
+ const clampedAmount = Math.min(Math.max(0, amount), participant.holdings);
+ const wasStaked = participant.stakedTokens > 0;
+ const isNowStaked = clampedAmount > 0;
+
+ const newEvents = [...state.events];
+ if (!wasStaked && isNowStaked) {
+ newEvents.push({
+ epoch: state.epoch,
+ type: 'stake',
+ participantId,
+ amount: clampedAmount,
+ description: `${participant.name} staked ${clampedAmount} tokens`,
+ });
+ } else if (wasStaked && !isNowStaked) {
+ newEvents.push({
+ epoch: state.epoch,
+ type: 'unstake',
+ participantId,
+ amount: 0,
+ description: `${participant.name} unstaked all tokens`,
+ });
+ }
+
+ return {
+ participants: state.participants.map((p) =>
+ p.id === participantId ? { ...p, stakedTokens: clampedAmount } : p
+ ),
+ events: newEvents,
+ };
+ });
+ },
+
+ toggleStake: (participantId) => {
+ const participant = get().participants.find((p) => p.id === participantId);
+ if (!participant) return;
+
+ if (participant.stakedTokens > 0) {
+ get().setStake(participantId, 0);
+ } else {
+ get().setStake(participantId, participant.holdings);
+ }
+ },
+
+ // Proposal actions
+ addProposal: (title, fundsRequested) => {
+ const { params } = get();
+ const newProposal: Proposal = {
+ id: `proposal-${Date.now()}`,
+ title,
+ description: '',
+ fundsRequested,
+ totalConviction: 0,
+ trigger: triggerThreshold(fundsRequested, params.funds, params.supply, params),
+ status: 'candidate',
+ age: 0,
+ convictionHistory: [0],
+ };
+ set((state) => ({
+ proposals: [...state.proposals, newProposal],
+ }));
+ },
+
+ // Params
+ setParams: (newParams) => {
+ set((state) => {
+ const params = { ...state.params, ...newParams };
+ // Recalculate triggers
+ const proposals = state.proposals.map((p) => ({
+ ...p,
+ trigger: triggerThreshold(p.fundsRequested, params.funds, params.supply, params),
+ }));
+ return { params, proposals };
+ });
+ },
+
+ setHalfLife: (days) => {
+ const alpha = alphaFromHalfLife(days);
+ get().setParams({ alpha });
+ },
+
+ // Simulation
+ tick: () => {
+ set((state) => {
+ const newEpoch = state.epoch + 1;
+
+ // Update individual convictions
+ const updatedParticipants = state.participants.map((p) => {
+ const newConviction = updateConviction(
+ p.conviction,
+ p.stakedTokens,
+ state.params.alpha
+ );
+ return {
+ ...p,
+ conviction: newConviction,
+ convictionHistory: [...p.convictionHistory, newConviction],
+ stakingHistory: [...p.stakingHistory, p.stakedTokens],
+ };
+ });
+
+ // Calculate total conviction and check triggers
+ const newEvents: SimulationEvent[] = [];
+ const updatedProposals = state.proposals.map((proposal) => {
+ if (proposal.status !== 'candidate') {
+ return {
+ ...proposal,
+ age: proposal.age + 1,
+ convictionHistory: [...proposal.convictionHistory, proposal.totalConviction],
+ };
+ }
+
+ const totalConviction = updatedParticipants.reduce(
+ (sum, p) => sum + p.conviction,
+ 0
+ );
+
+ const newAge = proposal.age + 1;
+ let newStatus: Proposal['status'] = proposal.status;
+
+ if (newAge >= state.params.tmin && totalConviction >= proposal.trigger) {
+ newStatus = 'active';
+ newEvents.push({
+ epoch: newEpoch,
+ type: 'proposal_passed',
+ proposalId: proposal.id,
+ description: `"${proposal.title}" passed!`,
+ });
+ }
+
+ return {
+ ...proposal,
+ totalConviction,
+ status: newStatus,
+ age: newAge,
+ convictionHistory: [...proposal.convictionHistory, totalConviction],
+ };
+ });
+
+ return {
+ epoch: newEpoch,
+ epochHistory: [...state.epochHistory, newEpoch],
+ participants: updatedParticipants,
+ proposals: updatedProposals,
+ events: [...state.events, ...newEvents],
+ };
+ });
+ },
+
+ start: () => {
+ if (intervalId) return;
+ set({ isRunning: true });
+ intervalId = setInterval(() => {
+ get().tick();
+ }, get().speed);
+ },
+
+ stop: () => {
+ if (intervalId) {
+ clearInterval(intervalId);
+ intervalId = null;
+ }
+ set({ isRunning: false });
+ },
+
+ reset: () => {
+ get().stop();
+ set({
+ epoch: 0,
+ epochHistory: [0],
+ participants: createInitialParticipants(),
+ proposals: [createInitialProposal(get().params)],
+ events: [],
+ narrativeStep: 0,
+ });
+ },
+
+ setSpeed: (speed) => {
+ set({ speed });
+ const { isRunning } = get();
+ if (isRunning) {
+ get().stop();
+ get().start();
+ }
+ },
+
+ // Narrative
+ narrativeStep: 0,
+ narrativeSteps: [],
+
+ nextNarrativeStep: () => {
+ const { narrativeStep, narrativeSteps } = get();
+ if (narrativeStep < narrativeSteps.length - 1) {
+ const nextStep = narrativeStep + 1;
+ narrativeSteps[nextStep]?.action();
+ set({ narrativeStep: nextStep });
+ }
+ },
+
+ prevNarrativeStep: () => {
+ const { narrativeStep } = get();
+ if (narrativeStep > 0) {
+ set({ narrativeStep: narrativeStep - 1 });
+ }
+ },
+
+ resetNarrative: () => {
+ get().reset();
+ set({ narrativeStep: 0 });
+ },
+ };
+
+ // Initialize narrative steps with store reference
+ store.narrativeSteps = createNarrativeSteps(store);
+
+ return store;
+});