feat: Add conviction voting interactive demo
- Core conviction math engine with charge/discharge dynamics - Interactive participant cards with capacitor visualization - D3.js conviction charts showing individual and collective conviction - Trigger threshold visualization with water tank metaphor - Dual mode: Narrative walkthrough and free simulation - Docker setup for deployment with Traefik labels Based on BlockScience cadCAD model and formal derivations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c06748f3da
commit
024bc3f2a3
|
|
@ -0,0 +1,10 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
.env*.local
|
||||
*.log
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
252
src/app/page.tsx
252
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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
Conviction Voting
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
<p className="text-sm text-gray-500">
|
||||
Interactive demonstration of continuous governance
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="flex gap-1 p-1 bg-gray-100 rounded-lg">
|
||||
{(['narrative', 'simulation'] as AppMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`
|
||||
px-4 py-2 rounded-md text-sm font-medium transition-all
|
||||
${
|
||||
mode === m
|
||||
? 'bg-white text-gray-800 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
{m === 'narrative' ? '📖 Narrative' : '🎮 Simulation'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left column - Controls or Narrative */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
{mode === 'narrative' ? (
|
||||
<NarrativePanel />
|
||||
) : (
|
||||
<ControlPanel />
|
||||
)}
|
||||
|
||||
{/* Quick explanation */}
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
||||
<h3 className="font-semibold text-blue-800 mb-2">
|
||||
How it works
|
||||
</h3>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>• <strong>Stake</strong> tokens to support proposals</li>
|
||||
<li>• Conviction <strong>charges up</strong> over time</li>
|
||||
<li>• Unstaking causes conviction to <strong>decay</strong></li>
|
||||
<li>• When collective conviction exceeds the <strong>trigger</strong>, the proposal passes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle column - Participants */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
<div
|
||||
className={`
|
||||
${highlight === 'participants' ? 'ring-4 ring-yellow-400 ring-opacity-50 rounded-xl' : ''}
|
||||
`}
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-4">
|
||||
Participants
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{participants.map((p) => (
|
||||
<ParticipantCard
|
||||
key={p.id}
|
||||
participantId={p.id}
|
||||
highlight={highlight === p.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column - Proposal and Chart */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
<TriggerProgress highlight={highlight === 'trigger'} />
|
||||
|
||||
<div
|
||||
className={`
|
||||
bg-white p-4 rounded-xl border border-gray-200
|
||||
${highlight === 'chart' ? 'ring-4 ring-yellow-400 ring-opacity-50' : ''}
|
||||
`}
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-4">
|
||||
Conviction Over Time
|
||||
</h2>
|
||||
<ConvictionChart height={280} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full-width chart for aggregate view */}
|
||||
<motion.div
|
||||
className="mt-8 bg-white p-6 rounded-xl border border-gray-200"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-2">
|
||||
Signal Processing View
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Individual participant convictions aggregate into a collective signal.
|
||||
The system acts as a low-pass filter, smoothing noisy preferences into
|
||||
stable decisions.
|
||||
</p>
|
||||
<ConvictionChart height={200} showTrigger />
|
||||
</motion.div>
|
||||
|
||||
{/* Mathematical reference */}
|
||||
<div className="mt-8 p-6 bg-gray-800 text-gray-100 rounded-xl">
|
||||
<h2 className="text-lg font-semibold mb-4">Mathematical Foundation</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 text-sm">
|
||||
<div>
|
||||
<h3 className="font-bold text-blue-400 mb-2">Conviction Update</h3>
|
||||
<code className="block bg-gray-900 p-2 rounded font-mono">
|
||||
y<sub>t+1</sub> = α × y<sub>t</sub> + x<sub>t</sub>
|
||||
</code>
|
||||
<p className="mt-2 text-gray-400">
|
||||
New conviction = decay × old + current stake
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-green-400 mb-2">Maximum Conviction</h3>
|
||||
<code className="block bg-gray-900 p-2 rounded font-mono">
|
||||
y<sub>max</sub> = x / (1 - α)
|
||||
</code>
|
||||
<p className="mt-2 text-gray-400">
|
||||
Equilibrium when stake is held constant
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-red-400 mb-2">Trigger Threshold</h3>
|
||||
<code className="block bg-gray-900 p-2 rounded font-mono">
|
||||
y* = ρS / ((1-α)(β - r/R)²)
|
||||
</code>
|
||||
<p className="mt-2 text-gray-400">
|
||||
Larger requests need exponentially more conviction
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credits */}
|
||||
<footer className="mt-8 text-center text-sm text-gray-500">
|
||||
<p>
|
||||
Based on research by{' '}
|
||||
<a
|
||||
href="https://block.science"
|
||||
className="text-blue-600 hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
BlockScience
|
||||
</a>
|
||||
{' '}and the{' '}
|
||||
<a
|
||||
href="https://commonsstack.org"
|
||||
className="text-blue-600 hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Commons Stack
|
||||
</a>
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
<a
|
||||
href="https://github.com/BlockScience/Aragon_Conviction_Voting"
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
View original cadCAD model →
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="p-4 bg-gray-50 rounded-xl border border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4 uppercase tracking-wide">
|
||||
Simulation Controls
|
||||
</h3>
|
||||
|
||||
{/* Playback controls */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{!isRunning ? (
|
||||
<button
|
||||
onClick={start}
|
||||
className="flex-1 py-2 px-4 bg-green-500 hover:bg-green-600 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
▶ Play
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={stop}
|
||||
className="flex-1 py-2 px-4 bg-yellow-500 hover:bg-yellow-600 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
⏸ Pause
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={tick}
|
||||
disabled={isRunning}
|
||||
className="py-2 px-4 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Step
|
||||
</button>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="py-2 px-4 bg-red-500 hover:bg-red-600 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Epoch counter */}
|
||||
<div className="text-center mb-4 py-2 bg-gray-100 rounded-lg">
|
||||
<span className="text-sm text-gray-500">Epoch: </span>
|
||||
<span className="text-2xl font-mono font-bold text-gray-800">{epoch}</span>
|
||||
</div>
|
||||
|
||||
{/* Speed control */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm text-gray-600 mb-1">
|
||||
Speed: {(1000 / speed).toFixed(1)}x
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={100}
|
||||
max={2000}
|
||||
step={100}
|
||||
value={2100 - speed}
|
||||
onChange={(e) => setSpeed(2100 - Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>Slow</span>
|
||||
<span>Fast</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alpha / Half-life control */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm text-gray-600 mb-1">
|
||||
Half-life: {halfLife.toFixed(1)} epochs (α = {params.alpha.toFixed(3)})
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={2}
|
||||
max={50}
|
||||
step={1}
|
||||
value={halfLife}
|
||||
onChange={(e) => {
|
||||
const newHalfLife = Number(e.target.value);
|
||||
const newAlpha = alphaFromHalfLife(newHalfLife);
|
||||
setParams({ alpha: newAlpha });
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>Fast decay</span>
|
||||
<span>Slow decay</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced params (collapsible) */}
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
|
||||
Advanced Parameters
|
||||
</summary>
|
||||
<div className="mt-2 space-y-3 pl-2 border-l-2 border-gray-200">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500">
|
||||
β (max share): {(params.beta * 100).toFixed(0)}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0.05}
|
||||
max={0.5}
|
||||
step={0.05}
|
||||
value={params.beta}
|
||||
onChange={(e) => setParams({ beta: Number(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500">
|
||||
ρ (scale): {params.rho.toFixed(4)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0.0001}
|
||||
max={0.01}
|
||||
step={0.0001}
|
||||
value={params.rho}
|
||||
onChange={(e) => setParams({ rho: Number(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500">
|
||||
Supply: {params.supply.toLocaleString()}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={10000}
|
||||
max={1000000}
|
||||
step={10000}
|
||||
value={params.supply}
|
||||
onChange={(e) => setParams({ supply: Number(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500">
|
||||
Funds: {params.funds.toLocaleString()}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
value={params.funds}
|
||||
onChange={(e) => setParams({ funds: Number(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Formula reference */}
|
||||
<div className="mt-4 p-3 bg-gray-100 rounded-lg text-xs font-mono text-gray-600">
|
||||
<div className="mb-1">
|
||||
<strong>Conviction:</strong> y<sub>t+1</sub> = α × y<sub>t</sub> + x
|
||||
</div>
|
||||
<div>
|
||||
<strong>Max:</strong> y<sub>max</sub> = x / (1 - α)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(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<number>()
|
||||
.x((_, i) => xScale(i))
|
||||
.y((d) => yScale(d))
|
||||
.curve(d3.curveMonotoneX);
|
||||
|
||||
// Area generator for individual convictions
|
||||
const area = d3
|
||||
.area<number>()
|
||||
.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 (
|
||||
<div ref={containerRef} className="w-full">
|
||||
<svg ref={svgRef} width="100%" height={height} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<motion.div
|
||||
className="p-6 bg-gradient-to-br from-indigo-50 to-purple-50 rounded-xl border-2 border-indigo-200"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{/* Progress indicator */}
|
||||
<div className="flex gap-1 mb-4">
|
||||
{narrativeSteps.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`
|
||||
h-1 flex-1 rounded-full transition-all duration-300
|
||||
${i <= narrativeStep ? 'bg-indigo-500' : 'bg-gray-200'}
|
||||
`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step counter */}
|
||||
<div className="text-sm text-indigo-600 font-medium mb-2">
|
||||
Step {narrativeStep + 1} of {narrativeSteps.length}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={narrativeStep}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-3">
|
||||
{currentStep.title}
|
||||
</h2>
|
||||
<p className="text-gray-600 leading-relaxed whitespace-pre-line">
|
||||
{currentStep.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={prevNarrativeStep}
|
||||
disabled={isFirst}
|
||||
className={`
|
||||
flex-1 py-2 px-4 rounded-lg font-medium transition-colors
|
||||
${
|
||||
isFirst
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
{isLast ? (
|
||||
<button
|
||||
onClick={() => 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 →
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={nextNarrativeStep}
|
||||
className="flex-1 py-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restart option */}
|
||||
<button
|
||||
onClick={resetNarrative}
|
||||
className="mt-3 w-full py-1 text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Restart from beginning
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<motion.div
|
||||
className={`
|
||||
relative p-4 rounded-xl border-2 transition-all duration-300
|
||||
${highlight ? 'ring-4 ring-yellow-400 ring-opacity-50' : ''}
|
||||
${isStaking ? 'border-opacity-100' : 'border-opacity-30'}
|
||||
`}
|
||||
style={{
|
||||
borderColor: color,
|
||||
backgroundColor: `${color}10`,
|
||||
}}
|
||||
animate={{
|
||||
scale: highlight ? 1.02 : 1,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="font-semibold text-gray-800">{name}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{holdings} tokens</span>
|
||||
</div>
|
||||
|
||||
{/* Conviction Bar (Capacitor visualization) */}
|
||||
<div className="relative h-16 mb-3 rounded-lg overflow-hidden bg-gray-100">
|
||||
{/* Max conviction indicator */}
|
||||
{isStaking && (
|
||||
<div
|
||||
className="absolute inset-0 border-2 border-dashed rounded-lg opacity-30"
|
||||
style={{ borderColor: color }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Current conviction fill */}
|
||||
<motion.div
|
||||
className="absolute bottom-0 left-0 right-0 rounded-b-lg"
|
||||
style={{ backgroundColor: color }}
|
||||
animate={{
|
||||
height: `${chargeLevel}%`,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 50 }}
|
||||
/>
|
||||
|
||||
{/* Conviction value */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-sm font-mono font-bold text-gray-700 bg-white/70 px-2 py-1 rounded">
|
||||
{conviction.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Charging animation */}
|
||||
{isStaking && isRunning && (
|
||||
<motion.div
|
||||
className="absolute bottom-0 left-0 right-0 h-1"
|
||||
style={{ backgroundColor: color }}
|
||||
animate={{
|
||||
opacity: [0.5, 1, 0.5],
|
||||
}}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stake button */}
|
||||
<button
|
||||
onClick={() => 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}`}
|
||||
</button>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div className="mt-2 text-xs text-center text-gray-500">
|
||||
{isStaking ? (
|
||||
<span>
|
||||
{chargeLevel.toFixed(0)}% charged
|
||||
{chargeLevel < 99 && ' (charging...)'}
|
||||
</span>
|
||||
) : conviction > 0 ? (
|
||||
<span>Discharging...</span>
|
||||
) : (
|
||||
<span>Ready to stake</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<motion.div
|
||||
className={`
|
||||
p-6 rounded-xl border-2 bg-white
|
||||
${highlight ? 'ring-4 ring-yellow-400 ring-opacity-50' : ''}
|
||||
${isPassed ? 'border-green-500' : 'border-gray-200'}
|
||||
`}
|
||||
animate={{
|
||||
scale: highlight ? 1.01 : 1,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-800">{title}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Requesting: {fundsRequested.toLocaleString()} funds
|
||||
</p>
|
||||
</div>
|
||||
{isPassed && (
|
||||
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm font-medium">
|
||||
PASSED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="relative h-8 bg-gray-100 rounded-full overflow-hidden mb-2">
|
||||
{/* Trigger threshold marker */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-red-500 z-10"
|
||||
style={{ left: '100%', transform: 'translateX(-2px)' }}
|
||||
/>
|
||||
|
||||
{/* Progress fill */}
|
||||
<motion.div
|
||||
className={`
|
||||
absolute top-0 bottom-0 left-0 rounded-full
|
||||
${isPassed ? 'bg-green-500' : 'bg-blue-500'}
|
||||
`}
|
||||
animate={{
|
||||
width: `${progressPercent}%`,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 50 }}
|
||||
/>
|
||||
|
||||
{/* Progress label */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-gray-700 bg-white/80 px-2 py-0.5 rounded">
|
||||
{progressPercent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<div>
|
||||
<span className="font-medium">Current: </span>
|
||||
<span className="font-mono">{totalConviction.toFixed(0)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Threshold: </span>
|
||||
<span className="font-mono text-red-600">
|
||||
{trigger === Infinity ? '∞' : trigger.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual metaphor: Water tank */}
|
||||
<div className="mt-4 relative h-24 border-2 border-gray-300 rounded-lg overflow-hidden bg-gradient-to-b from-gray-50 to-gray-100">
|
||||
{/* Water level */}
|
||||
<motion.div
|
||||
className={`
|
||||
absolute bottom-0 left-0 right-0
|
||||
${isPassed ? 'bg-green-400/60' : 'bg-blue-400/60'}
|
||||
`}
|
||||
animate={{
|
||||
height: `${progressPercent}%`,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 30 }}
|
||||
style={{
|
||||
background: isPassed
|
||||
? 'linear-gradient(to top, rgba(34, 197, 94, 0.7), rgba(134, 239, 172, 0.4))'
|
||||
: 'linear-gradient(to top, rgba(59, 130, 246, 0.7), rgba(147, 197, 253, 0.4))',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Threshold line */}
|
||||
<div className="absolute w-full border-t-2 border-red-500 border-dashed" style={{ top: '0%' }}>
|
||||
<span className="absolute right-1 -top-4 text-xs font-bold text-red-500">
|
||||
TRIGGER
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Ripple effect when filling */}
|
||||
{!isPassed && progressPercent > 0 && (
|
||||
<motion.div
|
||||
className="absolute left-1/2 w-full h-1 bg-white/30 rounded-full"
|
||||
style={{
|
||||
bottom: `${progressPercent}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
animate={{
|
||||
scaleX: [0.3, 1, 0.3],
|
||||
opacity: [0.3, 0.6, 0.3],
|
||||
}}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Explanation */}
|
||||
<p className="mt-3 text-xs text-gray-500 text-center">
|
||||
{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.'}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export { ParticipantCard } from './ParticipantCard';
|
||||
export { ConvictionChart } from './ConvictionChart';
|
||||
export { TriggerProgress } from './TriggerProgress';
|
||||
export { ControlPanel } from './ControlPanel';
|
||||
export { NarrativePanel } from './NarrativePanel';
|
||||
|
|
@ -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<ConvictionParams, 'alpha' | 'beta' | 'rho'>
|
||||
): 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<ConvictionParams, 'alpha' | 'beta' | 'rho'>
|
||||
): 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<ConvictionParams, 'beta' | 'rho'>
|
||||
): 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
|
||||
];
|
||||
|
|
@ -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<ConvictionParams>) => 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<SimulationStore>((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;
|
||||
});
|
||||
Loading…
Reference in New Issue