From 024bc3f2a388bd6b61076b6bc7538c87fca987eb Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 3 Jan 2026 09:18:41 +0100 Subject: [PATCH] feat: Add conviction voting interactive demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .dockerignore | 10 + Dockerfile | 44 ++ docker-compose.yml | 22 + next.config.ts | 2 +- package-lock.json | 798 ++++++++++++++++++++++++++++- package.json | 6 +- src/app/layout.tsx | 5 +- src/app/page.tsx | 254 +++++++-- src/components/ControlPanel.tsx | 185 +++++++ src/components/ConvictionChart.tsx | 213 ++++++++ src/components/NarrativePanel.tsx | 104 ++++ src/components/ParticipantCard.tsx | 128 +++++ src/components/TriggerProgress.tsx | 153 ++++++ src/components/index.ts | 5 + src/lib/conviction.ts | 392 ++++++++++++++ src/stores/simulation.ts | 513 +++++++++++++++++++ 16 files changed, 2773 insertions(+), 61 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/components/ControlPanel.tsx create mode 100644 src/components/ConvictionChart.tsx create mode 100644 src/components/NarrativePanel.tsx create mode 100644 src/components/ParticipantCard.tsx create mode 100644 src/components/TriggerProgress.tsx create mode 100644 src/components/index.ts create mode 100644 src/lib/conviction.ts create mode 100644 src/stores/simulation.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..247c67f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.next +.git +.gitignore +README.md +Dockerfile +docker-compose.yml +.dockerignore +.env*.local +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..52de86e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy built application +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +# Set ownership +RUN chown -R nextjs:nodejs /app + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d066707 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + conviction-voting-demo: + build: . + container_name: conviction-voting-demo + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.http.routers.conviction.rule=Host(`conviction.jeffemmett.com`)" + - "traefik.http.routers.conviction.entrypoints=web" + - "traefik.http.services.conviction.loadbalancer.server.port=3000" + networks: + - traefik-public + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +networks: + traefik-public: + external: true diff --git a/next.config.ts b/next.config.ts index e9ffa30..225e495 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: 'standalone', }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 256d897..accff10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,13 @@ "name": "conviction-voting-demo", "version": "0.1.0", "dependencies": { + "@types/d3": "^7.4.3", + "d3": "^7.9.0", + "framer-motion": "^12.23.26", "next": "16.1.1", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "zustand": "^5.0.9" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1524,6 +1528,259 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1531,6 +1788,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1559,7 +1822,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2596,6 +2859,15 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2629,9 +2901,410 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -2754,6 +3427,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3585,6 +4267,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/framer-motion": { + "version": "12.23.26", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz", + "integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3890,6 +4599,18 @@ "hermes-estree": "0.25.1" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3942,6 +4663,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4900,6 +5630,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5502,6 +6247,12 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5526,6 +6277,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -5581,6 +6338,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6533,6 +7296,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index a2c3b09..d1e1915 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,13 @@ "lint": "eslint" }, "dependencies": { + "@types/d3": "^7.4.3", + "d3": "^7.9.0", + "framer-motion": "^12.23.26", "next": "16.1.1", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "zustand": "^5.0.9" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..a197155 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,8 +13,9 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Conviction Voting Demo", + description: "Interactive demonstration of conviction voting dynamics - continuous charge up & discharge of collective preferences", + keywords: ["conviction voting", "governance", "DAO", "commons", "BlockScience"], }; export default function RootLayout({ diff --git a/src/app/page.tsx b/src/app/page.tsx index 295f8fd..579e10c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,64 +1,210 @@ -import Image from "next/image"; +'use client'; + +import { motion } from 'framer-motion'; +import { useSimulationStore, AppMode } from '@/stores/simulation'; +import { + ParticipantCard, + ConvictionChart, + TriggerProgress, + ControlPanel, + NarrativePanel, +} from '@/components'; export default function Home() { + const mode = useSimulationStore((s) => s.mode); + const setMode = useSimulationStore((s) => s.setMode); + const participants = useSimulationStore((s) => s.participants); + const narrativeStep = useSimulationStore((s) => s.narrativeStep); + const narrativeSteps = useSimulationStore((s) => s.narrativeSteps); + + const currentStep = narrativeSteps[narrativeStep]; + const highlight = currentStep?.highlight; + return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - + {/* Header */} +

+
+
+

+ Conviction Voting +

+

+ Interactive demonstration of continuous governance +

+
+ + {/* Mode toggle */} +
+ {(['narrative', 'simulation'] as AppMode[]).map((m) => ( + + ))} +
+
+
+ +
+ + + {/* Full-width chart for aggregate view */} + +

+ Signal Processing View +

+

+ Individual participant convictions aggregate into a collective signal. + The system acts as a low-pass filter, smoothing noisy preferences into + stable decisions.

+ +
+ + {/* Mathematical reference */} +
+

Mathematical Foundation

+
+
+

Conviction Update

+ + yt+1 = α × yt + xt + +

+ New conviction = decay × old + current stake +

+
+
+

Maximum Conviction

+ + ymax = x / (1 - α) + +

+ Equilibrium when stake is held constant +

+
+
+

Trigger Threshold

+ + y* = ρS / ((1-α)(β - r/R)²) + +

+ Larger requests need exponentially more conviction +

+
+
- + + {/* Credits */} +
); diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx new file mode 100644 index 0000000..090be98 --- /dev/null +++ b/src/components/ControlPanel.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { useSimulationStore } from '@/stores/simulation'; +import { halfLifeFromAlpha, alphaFromHalfLife } from '@/lib/conviction'; + +export function ControlPanel() { + const epoch = useSimulationStore((s) => s.epoch); + const isRunning = useSimulationStore((s) => s.isRunning); + const speed = useSimulationStore((s) => s.speed); + const params = useSimulationStore((s) => s.params); + + const start = useSimulationStore((s) => s.start); + const stop = useSimulationStore((s) => s.stop); + const tick = useSimulationStore((s) => s.tick); + const reset = useSimulationStore((s) => s.reset); + const setSpeed = useSimulationStore((s) => s.setSpeed); + const setParams = useSimulationStore((s) => s.setParams); + + const halfLife = halfLifeFromAlpha(params.alpha); + + return ( +
+

+ Simulation Controls +

+ + {/* Playback controls */} +
+ {!isRunning ? ( + + ) : ( + + )} + + +
+ + {/* Epoch counter */} +
+ Epoch: + {epoch} +
+ + {/* Speed control */} +
+ + setSpeed(2100 - Number(e.target.value))} + className="w-full" + /> +
+ Slow + Fast +
+
+ + {/* Alpha / Half-life control */} +
+ + { + const newHalfLife = Number(e.target.value); + const newAlpha = alphaFromHalfLife(newHalfLife); + setParams({ alpha: newAlpha }); + }} + className="w-full" + /> +
+ Fast decay + Slow decay +
+
+ + {/* Advanced params (collapsible) */} +
+ + Advanced Parameters + +
+
+ + setParams({ beta: Number(e.target.value) })} + className="w-full" + /> +
+
+ + setParams({ rho: Number(e.target.value) })} + className="w-full" + /> +
+
+ + setParams({ supply: Number(e.target.value) })} + className="w-full" + /> +
+
+ + setParams({ funds: Number(e.target.value) })} + className="w-full" + /> +
+
+
+ + {/* Formula reference */} +
+
+ Conviction: yt+1 = α × yt + x +
+
+ Max: ymax = x / (1 - α) +
+
+
+ ); +} diff --git a/src/components/ConvictionChart.tsx b/src/components/ConvictionChart.tsx new file mode 100644 index 0000000..54a8841 --- /dev/null +++ b/src/components/ConvictionChart.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import * as d3 from 'd3'; +import { useSimulationStore } from '@/stores/simulation'; + +interface ConvictionChartProps { + height?: number; + showTrigger?: boolean; +} + +export function ConvictionChart({ height = 300, showTrigger = true }: ConvictionChartProps) { + const svgRef = useRef(null); + const containerRef = useRef(null); + + const participants = useSimulationStore((s) => s.participants); + const proposals = useSimulationStore((s) => s.proposals); + const epochHistory = useSimulationStore((s) => s.epochHistory); + + useEffect(() => { + if (!svgRef.current || !containerRef.current) return; + + const svg = d3.select(svgRef.current); + const containerWidth = containerRef.current.clientWidth; + const width = containerWidth; + + const margin = { top: 20, right: 80, bottom: 40, left: 60 }; + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + // Clear previous content + svg.selectAll('*').remove(); + + // Get the current proposal's trigger + const proposal = proposals[0]; + const trigger = proposal?.trigger || Infinity; + + // Calculate max conviction for y-axis + const allConvictions = participants.flatMap((p) => p.convictionHistory); + const totalConvictions = proposal?.convictionHistory || [0]; + const maxY = Math.max( + d3.max(allConvictions) || 0, + d3.max(totalConvictions) || 0, + showTrigger && trigger !== Infinity ? trigger * 1.1 : 0, + 100 + ); + + // Scales + const xScale = d3 + .scaleLinear() + .domain([0, Math.max(epochHistory.length - 1, 10)]) + .range([0, innerWidth]); + + const yScale = d3.scaleLinear().domain([0, maxY]).range([innerHeight, 0]); + + // Create main group + const g = svg + .append('g') + .attr('transform', `translate(${margin.left},${margin.top})`); + + // Grid lines + g.append('g') + .attr('class', 'grid') + .attr('opacity', 0.1) + .call( + d3 + .axisLeft(yScale) + .tickSize(-innerWidth) + .tickFormat(() => '') + ); + + // Trigger line (if showing) + if (showTrigger && trigger !== Infinity) { + g.append('line') + .attr('x1', 0) + .attr('x2', innerWidth) + .attr('y1', yScale(trigger)) + .attr('y2', yScale(trigger)) + .attr('stroke', '#ef4444') + .attr('stroke-width', 2) + .attr('stroke-dasharray', '8,4'); + + g.append('text') + .attr('x', innerWidth + 5) + .attr('y', yScale(trigger)) + .attr('dy', '0.35em') + .attr('fill', '#ef4444') + .attr('font-size', '12px') + .attr('font-weight', 'bold') + .text('TRIGGER'); + } + + // Line generator + const line = d3 + .line() + .x((_, i) => xScale(i)) + .y((d) => yScale(d)) + .curve(d3.curveMonotoneX); + + // Area generator for individual convictions + const area = d3 + .area() + .x((_, i) => xScale(i)) + .y0(innerHeight) + .y1((d) => yScale(d)) + .curve(d3.curveMonotoneX); + + // Draw individual participant conviction areas (stacked feel) + participants.forEach((participant, index) => { + const data = participant.convictionHistory; + + // Area fill + g.append('path') + .datum(data) + .attr('fill', participant.color) + .attr('fill-opacity', 0.2) + .attr('d', area); + + // Line + g.append('path') + .datum(data) + .attr('fill', 'none') + .attr('stroke', participant.color) + .attr('stroke-width', 2) + .attr('d', line); + + // Current value dot + if (data.length > 0) { + g.append('circle') + .attr('cx', xScale(data.length - 1)) + .attr('cy', yScale(data[data.length - 1])) + .attr('r', 5) + .attr('fill', participant.color) + .attr('stroke', 'white') + .attr('stroke-width', 2); + } + }); + + // Draw total conviction line (bold) + if (proposal && totalConvictions.length > 0) { + g.append('path') + .datum(totalConvictions) + .attr('fill', 'none') + .attr('stroke', '#1f2937') + .attr('stroke-width', 3) + .attr('d', line); + + // Total label + g.append('text') + .attr('x', innerWidth + 5) + .attr('y', yScale(totalConvictions[totalConvictions.length - 1])) + .attr('dy', '0.35em') + .attr('fill', '#1f2937') + .attr('font-size', '12px') + .attr('font-weight', 'bold') + .text('TOTAL'); + } + + // X Axis + g.append('g') + .attr('transform', `translate(0,${innerHeight})`) + .call(d3.axisBottom(xScale).ticks(10)) + .append('text') + .attr('x', innerWidth / 2) + .attr('y', 35) + .attr('fill', '#374151') + .attr('text-anchor', 'middle') + .text('Epoch'); + + // Y Axis + g.append('g') + .call(d3.axisLeft(yScale).ticks(5)) + .append('text') + .attr('transform', 'rotate(-90)') + .attr('x', -innerHeight / 2) + .attr('y', -45) + .attr('fill', '#374151') + .attr('text-anchor', 'middle') + .text('Conviction'); + + // Legend + const legend = g + .append('g') + .attr('transform', `translate(10, 10)`); + + participants.forEach((p, i) => { + const legendRow = legend + .append('g') + .attr('transform', `translate(0, ${i * 18})`); + + legendRow + .append('rect') + .attr('width', 12) + .attr('height', 12) + .attr('fill', p.color) + .attr('rx', 2); + + legendRow + .append('text') + .attr('x', 18) + .attr('y', 10) + .attr('font-size', '11px') + .attr('fill', '#374151') + .text(p.name); + }); + }, [participants, proposals, epochHistory, height, showTrigger]); + + return ( +
+ +
+ ); +} diff --git a/src/components/NarrativePanel.tsx b/src/components/NarrativePanel.tsx new file mode 100644 index 0000000..7903b9d --- /dev/null +++ b/src/components/NarrativePanel.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { motion, AnimatePresence } from 'framer-motion'; +import { useSimulationStore } from '@/stores/simulation'; + +export function NarrativePanel() { + const narrativeStep = useSimulationStore((s) => s.narrativeStep); + const narrativeSteps = useSimulationStore((s) => s.narrativeSteps); + const nextNarrativeStep = useSimulationStore((s) => s.nextNarrativeStep); + const prevNarrativeStep = useSimulationStore((s) => s.prevNarrativeStep); + const resetNarrative = useSimulationStore((s) => s.resetNarrative); + const setMode = useSimulationStore((s) => s.setMode); + + const currentStep = narrativeSteps[narrativeStep]; + const isFirst = narrativeStep === 0; + const isLast = narrativeStep === narrativeSteps.length - 1; + + if (!currentStep) return null; + + return ( + + {/* Progress indicator */} +
+ {narrativeSteps.map((_, i) => ( +
+ ))} +
+ + {/* Step counter */} +
+ Step {narrativeStep + 1} of {narrativeSteps.length} +
+ + {/* Content */} + + +

+ {currentStep.title} +

+

+ {currentStep.description} +

+
+
+ + {/* Navigation */} +
+ + {isLast ? ( + + ) : ( + + )} +
+ + {/* Restart option */} + + + ); +} diff --git a/src/components/ParticipantCard.tsx b/src/components/ParticipantCard.tsx new file mode 100644 index 0000000..11822f0 --- /dev/null +++ b/src/components/ParticipantCard.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { useSimulationStore } from '@/stores/simulation'; +import { maxConviction } from '@/lib/conviction'; + +interface ParticipantCardProps { + participantId: string; + highlight?: boolean; +} + +export function ParticipantCard({ participantId, highlight }: ParticipantCardProps) { + const participant = useSimulationStore((s) => + s.participants.find((p) => p.id === participantId) + ); + const params = useSimulationStore((s) => s.params); + const toggleStake = useSimulationStore((s) => s.toggleStake); + const isRunning = useSimulationStore((s) => s.isRunning); + + if (!participant) return null; + + const { name, color, holdings, stakedTokens, conviction } = participant; + const isStaking = stakedTokens > 0; + const maxConv = maxConviction(stakedTokens, params.alpha); + const convictionPercent = maxConv > 0 ? (conviction / maxConv) * 100 : 0; + const chargeLevel = Math.min(convictionPercent, 100); + + return ( + + {/* Header */} +
+
+
+ {name} +
+ {holdings} tokens +
+ + {/* Conviction Bar (Capacitor visualization) */} +
+ {/* Max conviction indicator */} + {isStaking && ( +
+ )} + + {/* Current conviction fill */} + + + {/* Conviction value */} +
+ + {conviction.toFixed(0)} + +
+ + {/* Charging animation */} + {isStaking && isRunning && ( + + )} +
+ + {/* Stake button */} + + + {/* Status indicator */} +
+ {isStaking ? ( + + {chargeLevel.toFixed(0)}% charged + {chargeLevel < 99 && ' (charging...)'} + + ) : conviction > 0 ? ( + Discharging... + ) : ( + Ready to stake + )} +
+ + ); +} diff --git a/src/components/TriggerProgress.tsx b/src/components/TriggerProgress.tsx new file mode 100644 index 0000000..ced4726 --- /dev/null +++ b/src/components/TriggerProgress.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { useSimulationStore } from '@/stores/simulation'; +import { triggerProgress } from '@/lib/conviction'; + +interface TriggerProgressProps { + proposalId?: string; + highlight?: boolean; +} + +export function TriggerProgress({ proposalId, highlight }: TriggerProgressProps) { + const proposals = useSimulationStore((s) => s.proposals); + const proposal = proposalId + ? proposals.find((p) => p.id === proposalId) + : proposals[0]; + + if (!proposal) return null; + + const { title, fundsRequested, totalConviction, trigger, status } = proposal; + const progress = triggerProgress(totalConviction, trigger); + const progressPercent = Math.min(progress * 100, 100); + const isPassed = status === 'active' || status === 'completed'; + + return ( + + {/* Header */} +
+
+

{title}

+

+ Requesting: {fundsRequested.toLocaleString()} funds +

+
+ {isPassed && ( + + PASSED + + )} +
+ + {/* Progress bar */} +
+ {/* Trigger threshold marker */} +
+ + {/* Progress fill */} + + + {/* Progress label */} +
+ + {progressPercent.toFixed(1)}% + +
+
+ + {/* Stats */} +
+
+ Current: + {totalConviction.toFixed(0)} +
+
+ Threshold: + + {trigger === Infinity ? '∞' : trigger.toFixed(0)} + +
+
+ + {/* Visual metaphor: Water tank */} +
+ {/* Water level */} + + + {/* Threshold line */} +
+ + TRIGGER + +
+ + {/* Ripple effect when filling */} + {!isPassed && progressPercent > 0 && ( + + )} +
+ + {/* Explanation */} +

+ {isPassed + ? 'Collective conviction exceeded the threshold!' + : progressPercent > 80 + ? 'Almost there! Keep the conviction growing...' + : progressPercent > 50 + ? 'Past halfway! The bucket is filling up.' + : progressPercent > 0 + ? 'Conviction is accumulating. Hold steady.' + : 'Stake tokens to start building conviction.'} +

+ + ); +} diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..ccbc40e --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,5 @@ +export { ParticipantCard } from './ParticipantCard'; +export { ConvictionChart } from './ConvictionChart'; +export { TriggerProgress } from './TriggerProgress'; +export { ControlPanel } from './ControlPanel'; +export { NarrativePanel } from './NarrativePanel'; diff --git a/src/lib/conviction.ts b/src/lib/conviction.ts new file mode 100644 index 0000000..8eb7b72 --- /dev/null +++ b/src/lib/conviction.ts @@ -0,0 +1,392 @@ +/** + * Conviction Voting Math Engine + * + * Based on BlockScience's cadCAD model and formal derivations: + * - Deriving_Alpha.ipynb + * - Trigger_Function_Explanation.ipynb + * + * Core formula: y_{t+1} = α × y_t + x_t + * Where: + * y = conviction (accumulated support) + * x = tokens currently staked + * α = decay parameter ∈ (0,1) + */ + +// ============================================================================ +// Core Types +// ============================================================================ + +export interface Participant { + id: string; + name: string; + color: string; + holdings: number; // Total tokens available + stakedTokens: number; // Currently staked on proposal + conviction: number; // Accumulated conviction + convictionHistory: number[]; + stakingHistory: number[]; +} + +export interface Proposal { + id: string; + title: string; + description: string; + fundsRequested: number; + totalConviction: number; + trigger: number; // Threshold to pass + status: 'candidate' | 'active' | 'completed' | 'failed'; + age: number; + convictionHistory: number[]; +} + +export interface ConvictionParams { + alpha: number; // Decay parameter (0.5-0.99) + beta: number; // Max share of funds per proposal (e.g., 0.2) + rho: number; // Trigger function scale factor + supply: number; // Total token supply + funds: number; // Available funds in pool + tmin: number; // Minimum age before proposal can pass +} + +// ============================================================================ +// Alpha / Half-Life Conversion +// ============================================================================ + +/** + * Calculate alpha from desired half-life + * α = 2^(-1/T) + */ +export function alphaFromHalfLife(halfLifeEpochs: number): number { + return Math.pow(2, -1 / halfLifeEpochs); +} + +/** + * Calculate half-life from alpha + * T = -log_α(2) = -ln(2) / ln(α) + */ +export function halfLifeFromAlpha(alpha: number): number { + if (alpha <= 0 || alpha >= 1) { + throw new Error('Alpha must be in (0, 1)'); + } + return -Math.log(2) / Math.log(alpha); +} + +/** + * Convert half-life between time units + * e.g., 7 days with 24 epochs/day = 168 epoch half-life + */ +export function scaleHalfLife(halfLifeDays: number, epochsPerDay: number): number { + return halfLifeDays * epochsPerDay; +} + +// ============================================================================ +// Conviction Dynamics +// ============================================================================ + +/** + * Single step conviction update + * y_{t+1} = α × y_t + x_t + */ +export function updateConviction( + currentConviction: number, + stakedTokens: number, + alpha: number +): number { + return alpha * currentConviction + stakedTokens; +} + +/** + * Calculate maximum possible conviction for a given stake + * y_max = x / (1 - α) + * + * This is the equilibrium conviction when tokens are held constant indefinitely + */ +export function maxConviction(stakedTokens: number, alpha: number): number { + return stakedTokens / (1 - alpha); +} + +/** + * Calculate conviction at time T given initial conviction and constant stake + * y_T = α^T × y_0 + x × (1 - α^T) / (1 - α) + * + * Uses geometric series: Σ(α^t) from t=0 to T-1 = (1 - α^T) / (1 - α) + */ +export function convictionAtTime( + initialConviction: number, + stakedTokens: number, + alpha: number, + epochs: number +): number { + const alphaT = Math.pow(alpha, epochs); + const geometricSum = (1 - alphaT) / (1 - alpha); + return alphaT * initialConviction + stakedTokens * geometricSum; +} + +/** + * Calculate conviction decay (when tokens are unstaked, x=0) + * y_T = α^T × y_0 + */ +export function convictionDecay( + initialConviction: number, + alpha: number, + epochs: number +): number { + return Math.pow(alpha, epochs) * initialConviction; +} + +/** + * Time to reach a target conviction percentage (0-1) of max + * Solving: targetFraction = (1 - α^T) + * T = ln(1 - targetFraction) / ln(α) + */ +export function epochsToReachFraction( + targetFraction: number, + alpha: number +): number { + if (targetFraction <= 0 || targetFraction >= 1) { + return targetFraction <= 0 ? 0 : Infinity; + } + return Math.log(1 - targetFraction) / Math.log(alpha); +} + +// ============================================================================ +// Trigger Function +// ============================================================================ + +/** + * Calculate trigger threshold for a proposal + * y*(r) = ρS / ((1-α)(β - r/R)²) + * + * @param requested - Funds requested by proposal + * @param funds - Total available funds (R) + * @param supply - Total token supply (S) + * @param params - Conviction parameters (α, β, ρ) + */ +export function triggerThreshold( + requested: number, + funds: number, + supply: number, + params: Pick +): number { + const { alpha, beta, rho } = params; + const share = requested / funds; + + if (share >= beta) { + return Infinity; // Request too large + } + + const denominator = Math.pow(beta - share, 2) * (1 - alpha); + return (rho * supply) / denominator; +} + +/** + * Calculate minimum conviction required (for r=0) + * y*(0) = ρS / ((1-α)β²) + */ +export function minRequiredConviction( + supply: number, + params: Pick +): number { + return triggerThreshold(0, 1, supply, params); +} + +/** + * Calculate maximum achievable conviction + * y_max = S / (1-α) + */ +export function maxAchievableConviction( + supply: number, + alpha: number +): number { + return supply / (1 - alpha); +} + +/** + * Calculate maximum achievable request + * r_max = (β - √ρ) × R + */ +export function maxAchievableRequest( + funds: number, + params: Pick +): number { + const { beta, rho } = params; + return (beta - Math.sqrt(rho)) * funds; +} + +/** + * Calculate progress toward trigger (0-1, can exceed 1) + */ +export function triggerProgress( + conviction: number, + trigger: number +): number { + if (trigger === Infinity) return 0; + return conviction / trigger; +} + +// ============================================================================ +// Simulation Engine +// ============================================================================ + +export interface SimulationState { + epoch: number; + participants: Participant[]; + proposals: Proposal[]; + params: ConvictionParams; + events: SimulationEvent[]; +} + +export interface SimulationEvent { + epoch: number; + type: 'stake' | 'unstake' | 'proposal_passed' | 'proposal_failed' | 'participant_joined'; + participantId?: string; + proposalId?: string; + amount?: number; + description: string; +} + +/** + * Advance simulation by one epoch + */ +export function simulateEpoch(state: SimulationState): SimulationState { + const { participants, proposals, params } = state; + const newEpoch = state.epoch + 1; + const newEvents: SimulationEvent[] = []; + + // Update individual convictions + const updatedParticipants = participants.map(p => { + const newConviction = updateConviction(p.conviction, p.stakedTokens, params.alpha); + return { + ...p, + conviction: newConviction, + convictionHistory: [...p.convictionHistory, newConviction], + stakingHistory: [...p.stakingHistory, p.stakedTokens] + }; + }); + + // Calculate total conviction per proposal and check triggers + const updatedProposals = proposals.map(proposal => { + if (proposal.status !== 'candidate') { + return { + ...proposal, + age: proposal.age + 1, + convictionHistory: [...proposal.convictionHistory, proposal.totalConviction] + }; + } + + // Sum individual convictions for this proposal + const totalConviction = updatedParticipants.reduce( + (sum, p) => sum + p.conviction, + 0 + ); + + const newAge = proposal.age + 1; + const trigger = triggerThreshold( + proposal.fundsRequested, + params.funds, + params.supply, + params + ); + + let newStatus: Proposal['status'] = proposal.status; + + // Check if proposal passes + if (newAge >= params.tmin && totalConviction >= trigger) { + newStatus = 'active'; + newEvents.push({ + epoch: newEpoch, + type: 'proposal_passed', + proposalId: proposal.id, + description: `Proposal "${proposal.title}" passed with ${totalConviction.toFixed(0)} conviction (threshold: ${trigger.toFixed(0)})` + }); + } + + return { + ...proposal, + totalConviction, + trigger, + status: newStatus, + age: newAge, + convictionHistory: [...proposal.convictionHistory, totalConviction] + }; + }); + + return { + epoch: newEpoch, + participants: updatedParticipants, + proposals: updatedProposals, + params: state.params, + events: [...state.events, ...newEvents] + }; +} + +/** + * Generate charging curve data for visualization + * Shows how conviction builds up over time with constant stake + */ +export function generateChargingCurve( + stakedTokens: number, + alpha: number, + epochs: number +): { epoch: number; conviction: number; percentOfMax: number }[] { + const max = maxConviction(stakedTokens, alpha); + const data = []; + + for (let t = 0; t <= epochs; t++) { + const conviction = convictionAtTime(0, stakedTokens, alpha, t); + data.push({ + epoch: t, + conviction, + percentOfMax: (conviction / max) * 100 + }); + } + + return data; +} + +/** + * Generate discharge curve data for visualization + * Shows how conviction decays over time after unstaking + */ +export function generateDischargeCurve( + initialConviction: number, + alpha: number, + epochs: number +): { epoch: number; conviction: number; percentOfInitial: number }[] { + const data = []; + + for (let t = 0; t <= epochs; t++) { + const conviction = convictionDecay(initialConviction, alpha, t); + data.push({ + epoch: t, + conviction, + percentOfInitial: (conviction / initialConviction) * 100 + }); + } + + return data; +} + +// ============================================================================ +// Default Parameters (from BlockScience 1Hive example) +// ============================================================================ + +export const DEFAULT_PARAMS: ConvictionParams = { + alpha: 0.9, // ~7 epoch half-life + beta: 0.2, // Max 20% of funds per proposal + rho: 0.0025, // Trigger scale factor + supply: 100000, // Total token supply + funds: 50000, // Available funds + tmin: 3 // Min 3 epochs before passing +}; + +export const PARTICIPANT_COLORS = [ + '#FF6B6B', // Red + '#4ECDC4', // Teal + '#45B7D1', // Blue + '#96CEB4', // Green + '#FFEAA7', // Yellow + '#DDA0DD', // Plum + '#98D8C8', // Mint + '#F7DC6F', // Gold +]; diff --git a/src/stores/simulation.ts b/src/stores/simulation.ts new file mode 100644 index 0000000..081d549 --- /dev/null +++ b/src/stores/simulation.ts @@ -0,0 +1,513 @@ +import { create } from 'zustand'; +import { + Participant, + Proposal, + ConvictionParams, + SimulationEvent, + DEFAULT_PARAMS, + PARTICIPANT_COLORS, + updateConviction, + triggerThreshold, + alphaFromHalfLife, + halfLifeFromAlpha, +} from '@/lib/conviction'; + +// ============================================================================ +// Store Types +// ============================================================================ + +export type AppMode = 'simulation' | 'narrative'; + +export interface NarrativeStep { + id: number; + title: string; + description: string; + action: () => void; + highlight?: string; // Element to highlight +} + +interface SimulationStore { + // Mode + mode: AppMode; + setMode: (mode: AppMode) => void; + + // Simulation state + epoch: number; + isRunning: boolean; + speed: number; // ms per epoch + + // Entities + participants: Participant[]; + proposals: Proposal[]; + params: ConvictionParams; + events: SimulationEvent[]; + + // History for charts + epochHistory: number[]; + + // Actions + addParticipant: (name: string, holdings: number) => void; + removeParticipant: (id: string) => void; + setStake: (participantId: string, amount: number) => void; + toggleStake: (participantId: string) => void; + + addProposal: (title: string, fundsRequested: number) => void; + + setParams: (params: Partial) => void; + setHalfLife: (days: number) => void; + + // Simulation controls + tick: () => void; + start: () => void; + stop: () => void; + reset: () => void; + setSpeed: (speed: number) => void; + + // Narrative mode + narrativeStep: number; + narrativeSteps: NarrativeStep[]; + nextNarrativeStep: () => void; + prevNarrativeStep: () => void; + resetNarrative: () => void; +} + +// ============================================================================ +// Initial State +// ============================================================================ + +const createInitialParticipants = (): Participant[] => [ + { + id: 'alice', + name: 'Alice', + color: PARTICIPANT_COLORS[0], + holdings: 1000, + stakedTokens: 0, + conviction: 0, + convictionHistory: [0], + stakingHistory: [0], + }, + { + id: 'bob', + name: 'Bob', + color: PARTICIPANT_COLORS[1], + holdings: 800, + stakedTokens: 0, + conviction: 0, + convictionHistory: [0], + stakingHistory: [0], + }, + { + id: 'charlie', + name: 'Charlie', + color: PARTICIPANT_COLORS[2], + holdings: 1200, + stakedTokens: 0, + conviction: 0, + convictionHistory: [0], + stakingHistory: [0], + }, +]; + +const createInitialProposal = (params: ConvictionParams): Proposal => ({ + id: 'proposal-1', + title: 'Fund the Community Garden', + description: 'Build a community garden for local food production', + fundsRequested: 5000, + totalConviction: 0, + trigger: triggerThreshold(5000, params.funds, params.supply, params), + status: 'candidate', + age: 0, + convictionHistory: [0], +}); + +// ============================================================================ +// Narrative Steps +// ============================================================================ + +const createNarrativeSteps = (store: SimulationStore): NarrativeStep[] => [ + { + id: 0, + title: 'Welcome to Conviction Voting', + description: `Imagine a community pool of funds that needs to be allocated fairly. + Unlike traditional voting with fixed deadlines, conviction voting lets preferences + accumulate continuously over time - like water filling a bucket.`, + action: () => {}, + }, + { + id: 1, + title: 'Meet the Participants', + description: `Three community members - Alice, Bob, and Charlie - each hold tokens + that represent their voice in governance. They can stake these tokens on proposals + they support.`, + action: () => {}, + highlight: 'participants', + }, + { + id: 2, + title: 'Alice Stakes First', + description: `Alice believes strongly in the garden proposal. She stakes all 1000 + of her tokens. Watch how her conviction begins to charge up like a capacitor...`, + action: () => { + store.setStake('alice', 1000); + store.start(); + }, + highlight: 'alice', + }, + { + id: 3, + title: 'Conviction Grows Over Time', + description: `Notice how Alice's conviction doesn't jump instantly to maximum - + it grows asymptotically. With α=0.9, her conviction approaches 10x her staked + tokens (1000 / (1-0.9) = 10,000) over time.`, + action: () => {}, + highlight: 'chart', + }, + { + id: 4, + title: 'Bob Joins the Cause', + description: `Seeing Alice's commitment, Bob decides to stake 800 tokens too. + His conviction starts from zero and begins charging up, adding to the collective.`, + action: () => { + store.setStake('bob', 800); + }, + highlight: 'bob', + }, + { + id: 5, + title: 'Collective Conviction', + description: `The proposal tracks total conviction from all supporters. + Watch the collective bar approach the trigger threshold...`, + action: () => {}, + highlight: 'trigger', + }, + { + id: 6, + title: 'Charlie Has Doubts', + description: `Charlie initially supported the proposal but changed his mind. + What happens when someone unstakes? Let's have Bob unstake and watch the decay...`, + action: () => { + store.setStake('bob', 0); + }, + highlight: 'bob', + }, + { + id: 7, + title: 'Conviction Decays (The Leaky Bucket)', + description: `When Bob unstakes, his conviction doesn't vanish instantly - it + decays exponentially with the same half-life. This is the "leaky bucket" - + conviction slowly drains away without continued commitment.`, + action: () => {}, + highlight: 'chart', + }, + { + id: 8, + title: 'The Signal Processing View', + description: `Conviction voting is fundamentally signal processing: noisy, + fluctuating individual preferences are smoothed into a stable collective signal. + The α parameter controls how much "memory" the system has.`, + action: () => { + store.setStake('charlie', 1200); + store.setStake('bob', 800); + }, + }, + { + id: 9, + title: 'Crossing the Threshold', + description: `When collective conviction exceeds the trigger threshold, the + proposal passes! The threshold is higher for larger funding requests - the + community must show stronger, more sustained conviction for bigger asks.`, + action: () => {}, + highlight: 'trigger', + }, + { + id: 10, + title: 'Explore on Your Own', + description: `Now switch to Simulation mode to experiment freely! Try adjusting + the half-life, adding more participants, or creating competing proposals.`, + action: () => { + store.stop(); + }, + }, +]; + +// ============================================================================ +// Store Implementation +// ============================================================================ + +export const useSimulationStore = create((set, get) => { + let intervalId: NodeJS.Timeout | null = null; + + const store: SimulationStore = { + // Mode + mode: 'narrative', + setMode: (mode) => set({ mode }), + + // Simulation state + epoch: 0, + isRunning: false, + speed: 500, + + // Entities + participants: createInitialParticipants(), + proposals: [createInitialProposal(DEFAULT_PARAMS)], + params: DEFAULT_PARAMS, + events: [], + + // History + epochHistory: [0], + + // Participant actions + addParticipant: (name, holdings) => { + const id = `participant-${Date.now()}`; + const colorIndex = get().participants.length % PARTICIPANT_COLORS.length; + const newParticipant: Participant = { + id, + name, + color: PARTICIPANT_COLORS[colorIndex], + holdings, + stakedTokens: 0, + conviction: 0, + convictionHistory: [0], + stakingHistory: [0], + }; + set((state) => ({ + participants: [...state.participants, newParticipant], + events: [ + ...state.events, + { + epoch: state.epoch, + type: 'participant_joined', + participantId: id, + description: `${name} joined with ${holdings} tokens`, + }, + ], + })); + }, + + removeParticipant: (id) => { + set((state) => ({ + participants: state.participants.filter((p) => p.id !== id), + })); + }, + + setStake: (participantId, amount) => { + set((state) => { + const participant = state.participants.find((p) => p.id === participantId); + if (!participant) return state; + + const clampedAmount = Math.min(Math.max(0, amount), participant.holdings); + const wasStaked = participant.stakedTokens > 0; + const isNowStaked = clampedAmount > 0; + + const newEvents = [...state.events]; + if (!wasStaked && isNowStaked) { + newEvents.push({ + epoch: state.epoch, + type: 'stake', + participantId, + amount: clampedAmount, + description: `${participant.name} staked ${clampedAmount} tokens`, + }); + } else if (wasStaked && !isNowStaked) { + newEvents.push({ + epoch: state.epoch, + type: 'unstake', + participantId, + amount: 0, + description: `${participant.name} unstaked all tokens`, + }); + } + + return { + participants: state.participants.map((p) => + p.id === participantId ? { ...p, stakedTokens: clampedAmount } : p + ), + events: newEvents, + }; + }); + }, + + toggleStake: (participantId) => { + const participant = get().participants.find((p) => p.id === participantId); + if (!participant) return; + + if (participant.stakedTokens > 0) { + get().setStake(participantId, 0); + } else { + get().setStake(participantId, participant.holdings); + } + }, + + // Proposal actions + addProposal: (title, fundsRequested) => { + const { params } = get(); + const newProposal: Proposal = { + id: `proposal-${Date.now()}`, + title, + description: '', + fundsRequested, + totalConviction: 0, + trigger: triggerThreshold(fundsRequested, params.funds, params.supply, params), + status: 'candidate', + age: 0, + convictionHistory: [0], + }; + set((state) => ({ + proposals: [...state.proposals, newProposal], + })); + }, + + // Params + setParams: (newParams) => { + set((state) => { + const params = { ...state.params, ...newParams }; + // Recalculate triggers + const proposals = state.proposals.map((p) => ({ + ...p, + trigger: triggerThreshold(p.fundsRequested, params.funds, params.supply, params), + })); + return { params, proposals }; + }); + }, + + setHalfLife: (days) => { + const alpha = alphaFromHalfLife(days); + get().setParams({ alpha }); + }, + + // Simulation + tick: () => { + set((state) => { + const newEpoch = state.epoch + 1; + + // Update individual convictions + const updatedParticipants = state.participants.map((p) => { + const newConviction = updateConviction( + p.conviction, + p.stakedTokens, + state.params.alpha + ); + return { + ...p, + conviction: newConviction, + convictionHistory: [...p.convictionHistory, newConviction], + stakingHistory: [...p.stakingHistory, p.stakedTokens], + }; + }); + + // Calculate total conviction and check triggers + const newEvents: SimulationEvent[] = []; + const updatedProposals = state.proposals.map((proposal) => { + if (proposal.status !== 'candidate') { + return { + ...proposal, + age: proposal.age + 1, + convictionHistory: [...proposal.convictionHistory, proposal.totalConviction], + }; + } + + const totalConviction = updatedParticipants.reduce( + (sum, p) => sum + p.conviction, + 0 + ); + + const newAge = proposal.age + 1; + let newStatus: Proposal['status'] = proposal.status; + + if (newAge >= state.params.tmin && totalConviction >= proposal.trigger) { + newStatus = 'active'; + newEvents.push({ + epoch: newEpoch, + type: 'proposal_passed', + proposalId: proposal.id, + description: `"${proposal.title}" passed!`, + }); + } + + return { + ...proposal, + totalConviction, + status: newStatus, + age: newAge, + convictionHistory: [...proposal.convictionHistory, totalConviction], + }; + }); + + return { + epoch: newEpoch, + epochHistory: [...state.epochHistory, newEpoch], + participants: updatedParticipants, + proposals: updatedProposals, + events: [...state.events, ...newEvents], + }; + }); + }, + + start: () => { + if (intervalId) return; + set({ isRunning: true }); + intervalId = setInterval(() => { + get().tick(); + }, get().speed); + }, + + stop: () => { + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + set({ isRunning: false }); + }, + + reset: () => { + get().stop(); + set({ + epoch: 0, + epochHistory: [0], + participants: createInitialParticipants(), + proposals: [createInitialProposal(get().params)], + events: [], + narrativeStep: 0, + }); + }, + + setSpeed: (speed) => { + set({ speed }); + const { isRunning } = get(); + if (isRunning) { + get().stop(); + get().start(); + } + }, + + // Narrative + narrativeStep: 0, + narrativeSteps: [], + + nextNarrativeStep: () => { + const { narrativeStep, narrativeSteps } = get(); + if (narrativeStep < narrativeSteps.length - 1) { + const nextStep = narrativeStep + 1; + narrativeSteps[nextStep]?.action(); + set({ narrativeStep: nextStep }); + } + }, + + prevNarrativeStep: () => { + const { narrativeStep } = get(); + if (narrativeStep > 0) { + set({ narrativeStep: narrativeStep - 1 }); + } + }, + + resetNarrative: () => { + get().reset(); + set({ narrativeStep: 0 }); + }, + }; + + // Initialize narrative steps with store reference + store.narrativeSteps = createNarrativeSteps(store); + + return store; +});