Initial commit — GCP Dot self-hosted clone
Some checks failed
Build & publish Docker images / Egg image (push) Has been cancelled
Build & publish Docker images / Server image (push) Has been cancelled

Server (FastAPI + SQLite) runs Stouffer Z network variance analysis.
Egg container uses os.urandom for hardware-entropy 200-bit trials.
Gitea Actions workflow auto-builds and publishes both Docker images.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hexadual
2026-04-30 01:21:22 -05:00
commit ee2249c5f8
13 changed files with 1033 additions and 0 deletions

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
* text=auto
Dockerfile text eol=lf
*.sh text eol=lf
*.py text eol=lf

View File

@@ -0,0 +1,81 @@
name: Build & publish Docker images
on:
push:
branches: [main]
release:
types: [published]
env:
REGISTRY: git.hexadual.io
jobs:
server:
name: Server image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/rocobo/gcp-dot-server
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=sha,prefix=sha-,format=short
- name: Build and push server image
uses: docker/build-push-action@v5
with:
context: ./server
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
egg:
name: Egg image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to Gitea container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/rocobo/gcp-dot-egg
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=sha,prefix=sha-,format=short
- name: Build and push egg image
uses: docker/build-push-action@v5
with:
context: ./egg
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
__pycache__/
*.pyc
*.pyo
.env
*.db
.DS_Store

109
README.md Normal file
View File

@@ -0,0 +1,109 @@
# Global Consciousness Project — Self-Hosted
A live network-variance tracker inspired by the [Global Consciousness Project](https://global-mind.org).
Distributed "egg" containers contribute random numbers; the server runs statistical analysis and displays a colored dot.
## Architecture
```
Egg containers (anyone can run)
│ POST /api/data (one 200-bit trial per second)
Server (FastAPI + SQLite)
│ runs Stouffer Z network variance analysis every 60 s
Website → animated dot + history chart + embeddable iframe
```
## Quick start — run the server
```bash
git clone <this-repo>
cd gcp
docker compose up -d
```
The site is now at **http://localhost:8000**.
To expose it publicly, put it behind a reverse proxy (nginx, Caddy) or deploy to any VPS / cloud service.
## Contribute an egg
Anyone can run an egg — one Docker command:
```bash
docker run -d --restart unless-stopped \
-e SERVER_URL=https://your-domain.com \
-v gcp_egg_data:/data \
git.hexadual.io/rocobo/gcp-dot-egg:latest
```
The egg:
- Reads 200-bit trials from the OS hardware-entropy pool (`os.urandom`) once per second
- Sends the trial count (0200) to the server
- Persists its ID across restarts via `/data/egg_id`
## Images (built automatically by Gitea Actions)
Every push to `main` builds and publishes both images to the Gitea container registry:
| Image | Pull command |
|-------|-------------|
| Server | `docker pull git.hexadual.io/rocobo/gcp-dot-server:latest` |
| Egg | `docker pull git.hexadual.io/rocobo/gcp-dot-egg:latest` |
## How the analysis works
Every 60 seconds the server analyses the past hour of data:
1. **Normalise** each trial to a Z-score: `z = (trial 100) / √50`
(expected mean = 100, variance = 50 for Binomial(200, 0.5))
2. **Stouffer Z** per second across all active eggs: `S_t = Σzᵢₜ / √N`
3. **Network variance**: `V = Σ S_t²` — follows χ²(T) under H₀
4. **Index** = lower-tail CDF × 100:
- **High index (> 95)** → small variance → eggs are more coherent than chance → **blue**
- **Low index (< 5)** → large variance → eggs are noisier than chance → **red**
- **Middle (4090)** → normal random behavior → **green**
### Color table
| Color | Index | Meaning |
|--------|-----------|---------|
| Blue | > 95% | Significantly small variance — deep coherence |
| Cyan | 9095% | Small variance — probable coherence |
| Green | 4090% | Normal random behavior |
| Yellow | 1040% | Slightly elevated variance |
| Orange | 510% | Strongly elevated variance |
| Red | < 5% | Significantly large variance |
## Embed the dot
```html
<iframe src="https://your-domain.com/gcp.html"
height="48" width="48" scrolling="no" frameborder="0"></iframe>
```
## API
| Endpoint | Description |
|----------|-------------|
| `POST /api/data` | Submit a trial `{egg_id, timestamp, trial}` |
| `GET /api/status` | Latest analysis result |
| `GET /api/history?limit=60` | Last N analysis records |
| `GET /api/eggs` | Eggs active in the last 2 minutes |
## Environment variables
### Server
| Variable | Default | Description |
|----------|---------|-------------|
| `DB_PATH` | `/data/gcp.db` | SQLite database path |
### Egg
| Variable | Default | Description |
|----------|---------|-------------|
| `SERVER_URL` | `http://localhost:8000` | Server to send data to |
| `EGG_ID` | auto-generated | Override the egg's identifier |

13
docker-compose.yml Normal file
View File

@@ -0,0 +1,13 @@
services:
server:
build: ./server
ports:
- "8000:8000"
volumes:
- gcp_data:/data
restart: unless-stopped
environment:
- DB_PATH=/data/gcp.db
volumes:
gcp_data:

12
egg/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY egg.py .
VOLUME ["/data"]
CMD ["python", "egg.py"]

71
egg/egg.py Normal file
View File

@@ -0,0 +1,71 @@
import os
import time
import hashlib
import platform
import requests
ID_FILE = "/data/egg_id"
def generate_trial():
"""Sum of 200 random bits drawn from the OS CSPRNG (hardware entropy pool)."""
raw = os.urandom(25) # 25 bytes = 200 bits
return sum(bin(b).count("1") for b in raw)
def load_or_create_id():
try:
with open(ID_FILE) as f:
egg_id = f.read().strip()
if egg_id:
return egg_id
except OSError:
pass
egg_id = hashlib.sha256(os.urandom(32)).hexdigest()[:16]
try:
os.makedirs("/data", exist_ok=True)
with open(ID_FILE, "w") as f:
f.write(egg_id)
except OSError:
pass
return egg_id
def main():
server_url = os.environ.get("SERVER_URL", "http://localhost:8000").rstrip("/")
egg_id = os.environ.get("EGG_ID") or load_or_create_id()
print(f"GCP Egg id={egg_id} server={server_url} platform={platform.system()}")
session = requests.Session()
errors = 0
while True:
loop_start = time.time()
timestamp = int(loop_start)
trial = generate_trial()
try:
resp = session.post(
f"{server_url}/api/data",
json={"egg_id": egg_id, "timestamp": timestamp, "trial": trial},
timeout=5,
)
if resp.status_code == 200:
errors = 0
else:
errors += 1
if errors % 10 == 1:
print(f"Server returned {resp.status_code} (error #{errors})")
except Exception as exc:
errors += 1
if errors % 10 == 1:
print(f"Send error (#{errors}): {exc}")
elapsed = time.time() - loop_start
time.sleep(max(0.0, 1.0 - elapsed))
if __name__ == "__main__":
main()

1
egg/requirements.txt Normal file
View File

@@ -0,0 +1 @@
requests==2.31.0

15
server/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
COPY static/ ./static/
VOLUME ["/data"]
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

231
server/main.py Normal file
View File

@@ -0,0 +1,231 @@
"""
GCP Server — collects random trials from distributed eggs, runs Stouffer Z
network variance analysis every 60 seconds, and serves the dot website.
"""
import os
import sqlite3
import threading
import time
from collections import defaultdict
from contextlib import contextmanager
import numpy as np
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from scipy import stats
DB_PATH = os.environ.get("DB_PATH", "/data/gcp.db")
ANALYSIS_INTERVAL = 60 # seconds between analysis runs
DATA_OFFSET = 60 # analyse data at least 60 s old (propagation buffer)
WINDOW_SECONDS = 3600 # 1-hour analysis window (matches GCP primary window)
KEEP_SECONDS = 48 * 3600 # purge raw trials older than 48 h
MIN_EGGS = 2 # need at least 2 eggs to compute network variance
MIN_SAMPLES = 10 # need at least 10 time-steps in window
# ---------------------------------------------------------------------------
# Database helpers
# ---------------------------------------------------------------------------
def init_db():
with db() as con:
con.executescript("""
CREATE TABLE IF NOT EXISTS trials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
egg_id TEXT NOT NULL,
timestamp INTEGER NOT NULL,
trial INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_trials_ts ON trials(timestamp);
CREATE TABLE IF NOT EXISTS analysis (
timestamp INTEGER PRIMARY KEY,
index_value REAL NOT NULL,
n_eggs INTEGER NOT NULL,
n_samples INTEGER NOT NULL,
window_seconds INTEGER NOT NULL
);
""")
@contextmanager
def db():
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
try:
yield con
con.commit()
finally:
con.close()
# ---------------------------------------------------------------------------
# Statistical analysis
# ---------------------------------------------------------------------------
def compute_index(rows):
"""
Compute the GCP network variance index from raw trial rows.
Each egg produces one 200-bit trial per second: trial = count of 1-bits.
Under H0: trial ~ Binomial(200, 0.5), mean=100, var=50.
Steps:
1. Normalise each trial to a Z-score.
2. For each second, combine eggs via Stouffer Z.
3. Sum squared Stouffer Zs → network variance statistic ~ χ²(T).
4. Index = lower-tail CDF × 100.
High index (>95) = small variance = coherence = blue.
Low index (<5) = large variance = noise spike = red.
"""
# Build {timestamp: {egg_id: trial}} dict
by_ts = defaultdict(dict)
for row in rows:
by_ts[row["timestamp"]][row["egg_id"]] = row["trial"]
stouffer_sq = []
for ts in sorted(by_ts):
egg_vals = list(by_ts[ts].values())
if len(egg_vals) < MIN_EGGS:
continue
z_scores = [(v - 100) / np.sqrt(50) for v in egg_vals]
n = len(z_scores)
stouffer = sum(z_scores) / np.sqrt(n)
stouffer_sq.append(stouffer ** 2)
if len(stouffer_sq) < MIN_SAMPLES:
return None, 0, 0
T = len(stouffer_sq)
net_var = float(np.sum(stouffer_sq))
index_value = float(stats.chi2.cdf(net_var, df=T)) * 100.0
n_eggs = len({row["egg_id"] for row in rows})
return index_value, n_eggs, T
def analysis_loop():
"""Background thread: run analysis every ANALYSIS_INTERVAL seconds."""
# Wait a bit on startup so the DB is definitely initialised.
time.sleep(10)
while True:
try:
now = int(time.time())
end = now - DATA_OFFSET
start = end - WINDOW_SECONDS
with db() as con:
rows = con.execute(
"SELECT egg_id, timestamp, trial FROM trials "
"WHERE timestamp BETWEEN ? AND ? ORDER BY timestamp",
(start, end),
).fetchall()
index_value, n_eggs, n_samples = compute_index(rows)
if index_value is not None:
with db() as con:
con.execute(
"INSERT OR REPLACE INTO analysis "
"(timestamp, index_value, n_eggs, n_samples, window_seconds) "
"VALUES (?, ?, ?, ?, ?)",
(now, index_value, n_eggs, n_samples, WINDOW_SECONDS),
)
print(
f"[analysis] index={index_value:.1f}% eggs={n_eggs} samples={n_samples}"
)
# Purge old raw data
with db() as con:
con.execute(
"DELETE FROM trials WHERE timestamp < ?", (now - KEEP_SECONDS,)
)
except Exception as exc:
print(f"[analysis] error: {exc}")
time.sleep(ANALYSIS_INTERVAL)
# ---------------------------------------------------------------------------
# FastAPI app
# ---------------------------------------------------------------------------
app = FastAPI(title="Global Consciousness Project")
class TrialData(BaseModel):
egg_id: str = Field(..., min_length=1, max_length=64)
timestamp: int
trial: int = Field(..., ge=0, le=200)
@app.on_event("startup")
def startup():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
init_db()
threading.Thread(target=analysis_loop, daemon=True, name="analysis").start()
@app.post("/api/data")
def receive_trial(data: TrialData):
now = int(time.time())
# Reject data more than 5 minutes out of sync
if abs(data.timestamp - now) > 300:
raise HTTPException(status_code=422, detail="timestamp out of range")
with db() as con:
con.execute(
"INSERT INTO trials (egg_id, timestamp, trial) VALUES (?, ?, ?)",
(data.egg_id, data.timestamp, data.trial),
)
return {"status": "ok"}
@app.get("/api/status")
def get_status():
with db() as con:
row = con.execute(
"SELECT * FROM analysis ORDER BY timestamp DESC LIMIT 1"
).fetchone()
if row:
return {
"index": round(row["index_value"], 2),
"n_eggs": row["n_eggs"],
"n_samples": row["n_samples"],
"timestamp": row["timestamp"],
}
return {"index": 50.0, "n_eggs": 0, "n_samples": 0, "timestamp": 0}
@app.get("/api/history")
def get_history(limit: int = 60):
with db() as con:
rows = con.execute(
"SELECT timestamp, index_value, n_eggs FROM analysis "
"ORDER BY timestamp DESC LIMIT ?",
(min(limit, 1440),),
).fetchall()
return [
{"timestamp": r["timestamp"], "index": round(r["index_value"], 2), "n_eggs": r["n_eggs"]}
for r in rows
]
@app.get("/api/eggs")
def get_active_eggs():
"""Return eggs that have submitted data in the last 2 minutes."""
cutoff = int(time.time()) - 120
with db() as con:
rows = con.execute(
"SELECT DISTINCT egg_id, MAX(timestamp) as last_seen FROM trials "
"WHERE timestamp > ? GROUP BY egg_id ORDER BY last_seen DESC",
(cutoff,),
).fetchall()
return [{"egg_id": r["egg_id"], "last_seen": r["last_seen"]} for r in rows]
# Serve static files last so API routes take priority
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")

5
server/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi==0.110.0
uvicorn[standard]==0.27.1
numpy==1.26.4
scipy==1.12.0
pydantic==2.6.3

51
server/static/gcp.html Normal file
View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 48px; height: 48px; overflow: hidden; background: transparent; }
.dot {
width: 40px;
height: 40px;
border-radius: 50%;
background: #00cc44;
box-shadow: 0 0 14px 4px rgba(0,204,68,.6);
position: absolute;
top: 4px; left: 4px;
transition: background 2.5s ease, box-shadow 2.5s ease;
animation: breathe 4s ease-in-out infinite;
}
@keyframes breathe {
0%,100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
</style>
</head>
<body>
<div class="dot" id="dot" title="Global Consciousness Index"></div>
<script>
function palette(i) {
if (i > 95) return ['#0055ff','rgba(0,85,255,.6)'];
if (i > 90) return ['#00ccff','rgba(0,204,255,.6)'];
if (i > 40) return ['#00cc44','rgba(0,204,68,.6)'];
if (i > 10) return ['#ffdd00','rgba(255,221,0,.6)'];
if (i > 5) return ['#ff8800','rgba(255,136,0,.6)'];
return ['#ff2200','rgba(255,34,0,.6)'];
}
const dot = document.getElementById('dot');
async function update() {
try {
const r = await fetch('/api/status');
const d = await r.json();
const [color, glow] = palette(d.index);
dot.style.background = color;
dot.style.boxShadow = `0 0 14px 4px ${glow}`;
dot.title = `GCP Index: ${Math.round(d.index)}% (${d.n_eggs} eggs)`;
} catch {}
}
update();
setInterval(update, 60000);
</script>
</body>
</html>

434
server/static/index.html Normal file
View File

@@ -0,0 +1,434 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Global Consciousness Project — Live Dot</title>
<style>
:root {
--dot-color: #00cc44;
--glow: rgba(0,204,68,.35);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #080812;
color: #b0b0c8;
font-family: 'Segoe UI', system-ui, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2.5rem 1rem 4rem;
gap: 0;
}
/* ── header ── */
h1 {
font-size: 1rem;
font-weight: 300;
letter-spacing: .25em;
text-transform: uppercase;
color: #505070;
margin-bottom: .4rem;
}
.subtitle {
font-size: .75rem;
letter-spacing: .12em;
color: #303050;
text-transform: uppercase;
}
/* ── dot ── */
.dot-wrap {
position: relative;
width: 220px;
height: 220px;
margin: 2.5rem 0 1.5rem;
flex-shrink: 0;
}
.dot {
width: 160px;
height: 160px;
border-radius: 50%;
background: var(--dot-color);
box-shadow:
0 0 50px 18px var(--glow),
0 0 110px 40px var(--glow);
position: absolute;
inset: 0;
margin: auto;
transition: background 2.5s ease, box-shadow 2.5s ease;
animation: breathe 4s ease-in-out infinite;
}
@keyframes breathe {
0%,100% { transform: scale(1); }
50% { transform: scale(1.04); }
}
/* ── status label ── */
.status-label {
font-size: 1rem;
color: #d0d0e8;
text-align: center;
min-height: 1.6em;
transition: color 2s ease;
}
.status-label strong { color: #fff; }
/* ── stats row ── */
.stats {
display: flex;
gap: 3rem;
margin: 2rem 0 1rem;
}
.stat { text-align: center; }
.stat .val {
display: block;
font-size: 1.8rem;
font-weight: 200;
color: #9090b8;
line-height: 1;
}
.stat .lbl {
display: block;
font-size: .65rem;
letter-spacing: .12em;
text-transform: uppercase;
color: #383858;
margin-top: .3rem;
}
/* ── history chart ── */
#chart {
display: block;
width: min(480px, 96vw);
height: 72px;
margin: .5rem 0 2rem;
}
/* ── legend ── */
.card {
width: min(480px, 96vw);
background: #0e0e1c;
border: 1px solid #18182a;
border-radius: 12px;
padding: 1.4rem 1.6rem;
margin-bottom: 1rem;
}
.card-title {
font-size: .65rem;
letter-spacing: .14em;
text-transform: uppercase;
color: #303050;
margin-bottom: 1rem;
}
.legend-row {
display: flex;
align-items: center;
gap: .75rem;
padding: .35rem 0;
font-size: .82rem;
color: #7070a0;
border-bottom: 1px solid #131325;
}
.legend-row:last-child { border-bottom: none; }
.ldot {
width: 11px;
height: 11px;
border-radius: 50%;
flex-shrink: 0;
}
.legend-range {
margin-left: auto;
font-size: .7rem;
color: #383858;
white-space: nowrap;
}
/* ── embed box ── */
.embed-code {
background: #080812;
border: 1px solid #1a1a2e;
border-radius: 6px;
padding: .7rem 1rem;
font-family: monospace;
font-size: .75rem;
color: #6060a0;
word-break: break-all;
cursor: pointer;
transition: border-color .2s;
}
.embed-code:hover { border-color: #404068; }
.copy-hint {
font-size: .65rem;
color: #282840;
margin-top: .4rem;
}
/* ── footer ── */
footer {
margin-top: 2rem;
font-size: .75rem;
color: #282840;
text-align: center;
line-height: 1.8;
}
footer a { color: #383858; }
/* ── tick ── */
#tick {
font-size: .65rem;
color: #282840;
margin-top: 1.5rem;
letter-spacing: .05em;
}
/* ── copied toast ── */
#toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%) translateY(4rem);
background: #1c1c30;
color: #8080c0;
padding: .5rem 1.2rem;
border-radius: 99px;
font-size: .8rem;
transition: transform .3s ease;
pointer-events: none;
}
#toast.show { transform: translateX(-50%) translateY(0); }
</style>
</head>
<body>
<h1>Global Consciousness Project</h1>
<p class="subtitle">Real-time network variance</p>
<div class="dot-wrap">
<div class="dot" id="dot"></div>
</div>
<p class="status-label" id="status-label">Connecting…</p>
<div class="stats">
<div class="stat">
<span class="val" id="s-index"></span>
<span class="lbl">Index</span>
</div>
<div class="stat">
<span class="val" id="s-eggs"></span>
<span class="lbl">Active Eggs</span>
</div>
<div class="stat">
<span class="val" id="s-age"></span>
<span class="lbl">Updated</span>
</div>
</div>
<canvas id="chart"></canvas>
<!-- legend -->
<div class="card">
<p class="card-title">What the color means</p>
<div class="legend-row">
<div class="ldot" style="background:#0055ff;box-shadow:0 0 6px #0055ff"></div>
Deeply coherent — significantly small network variance
<span class="legend-range">&gt; 95%</span>
</div>
<div class="legend-row">
<div class="ldot" style="background:#00ccff;box-shadow:0 0 6px #00ccff"></div>
Small variance — probably coherence
<span class="legend-range">90 95%</span>
</div>
<div class="legend-row">
<div class="ldot" style="background:#00cc44;box-shadow:0 0 6px #00cc44"></div>
Normal — expected random behavior
<span class="legend-range">40 90%</span>
</div>
<div class="legend-row">
<div class="ldot" style="background:#ffdd00;box-shadow:0 0 6px #ffdd00"></div>
Slightly elevated variance — probably chance
<span class="legend-range">10 40%</span>
</div>
<div class="legend-row">
<div class="ldot" style="background:#ff8800;box-shadow:0 0 6px #ff8800"></div>
Strongly elevated variance — possible shared focus
<span class="legend-range">5 10%</span>
</div>
<div class="legend-row">
<div class="ldot" style="background:#ff2200;box-shadow:0 0 6px #ff2200"></div>
Significantly large variance — broadly shared emotion
<span class="legend-range">&lt; 5%</span>
</div>
</div>
<!-- embed -->
<div class="card">
<p class="card-title">Embed on your site</p>
<div class="embed-code" id="embed-code" onclick="copyEmbed()" title="Click to copy"></div>
<p class="copy-hint">Click to copy · The dot updates every 60 seconds</p>
</div>
<footer>
<p>Methodology based on the <a href="https://global-mind.org" target="_blank" rel="noopener">Global Consciousness Project</a> by Roger Nelson.</p>
<p>Contribute an egg: <code>docker run -e SERVER_URL=<span class="origin-span"></span> yourname/gcp-egg</code></p>
</footer>
<p id="tick"></p>
<div id="toast">Copied!</div>
<script>
// ── color mapping ──────────────────────────────────────────────────────
function palette(index) {
if (index > 95) return ['#0055ff', 'rgba(0,85,255,.35)'];
if (index > 90) return ['#00ccff', 'rgba(0,204,255,.35)'];
if (index > 40) return ['#00cc44', 'rgba(0,204,68,.35)'];
if (index > 10) return ['#ffdd00', 'rgba(255,221,0,.35)'];
if (index > 5) return ['#ff8800', 'rgba(255,136,0,.35)'];
return ['#ff2200', 'rgba(255,34,0,.35)'];
}
function label(index, n_eggs) {
if (!n_eggs) return 'No eggs connected — waiting for data';
if (index > 95) return '<strong>Deeply coherent</strong> — significantly small network variance';
if (index > 90) return '<strong>Slightly coherent</strong> — probably chance fluctuation';
if (index > 40) return '<strong>Normal</strong> — expected random behavior';
if (index > 10) return '<strong>Slightly elevated</strong> variance — probably chance';
if (index > 5) return '<strong>Strongly elevated</strong> variance — possible shared focus';
return '<strong>Significantly large</strong> variance — broadly shared emotion';
}
// ── dot ───────────────────────────────────────────────────────────────
const dotEl = document.getElementById('dot');
function applyColor(index) {
const [color, glow] = palette(index);
dotEl.style.background = color;
dotEl.style.boxShadow = `0 0 50px 18px ${glow}, 0 0 110px 40px ${glow}`;
}
// ── chart ─────────────────────────────────────────────────────────────
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
function drawChart(history) {
const W = canvas.offsetWidth || 480;
const H = 72;
canvas.width = W * devicePixelRatio;
canvas.height = H * devicePixelRatio;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
ctx.scale(devicePixelRatio, devicePixelRatio);
if (!history.length) return;
const n = history.length;
const step = W / Math.max(n - 1, 1);
const pad = 4;
ctx.clearRect(0, 0, W, H);
// Draw subtle grid lines at 5%, 40%, 90%, 95%
[5, 40, 90, 95].forEach(pct => {
const y = H - pad - ((pct / 100) * (H - 2 * pad));
ctx.beginPath();
ctx.strokeStyle = '#14142a';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.moveTo(0, y);
ctx.lineTo(W, y);
ctx.stroke();
ctx.setLineDash([]);
});
// Draw polyline
history.forEach((pt, i) => {
const x = i * step;
const y = H - pad - (pt.index / 100) * (H - 2 * pad);
const [color] = palette(pt.index);
if (i === 0) {
ctx.beginPath();
ctx.moveTo(x, y);
} else {
const prev = history[i - 1];
const px = (i - 1) * step;
const py = H - pad - (prev.index / 100) * (H - 2 * pad);
ctx.lineTo(x, y);
ctx.strokeStyle = color + 'aa';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, y);
}
// dot
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
});
}
// ── fetch & render ─────────────────────────────────────────────────────
async function fetchStatus() {
const r = await fetch('/api/status');
return r.json();
}
async function fetchHistory() {
const r = await fetch('/api/history?limit=60');
return r.json();
}
function ageStr(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now() / 1000) - ts;
if (d < 60) return d + 's';
if (d < 3600) return Math.floor(d / 60) + 'm';
return Math.floor(d / 3600) + 'h';
}
async function update() {
try {
const [status, history] = await Promise.all([fetchStatus(), fetchHistory()]);
applyColor(status.index);
document.getElementById('status-label').innerHTML = label(status.index, status.n_eggs);
document.getElementById('s-index').textContent = Math.round(status.index) + '%';
document.getElementById('s-eggs').textContent = status.n_eggs;
document.getElementById('s-age').textContent = ageStr(status.timestamp);
drawChart(history.slice().reverse());
} catch (e) {
document.getElementById('status-label').textContent = 'Connection error — retrying…';
}
}
// ── embed code ────────────────────────────────────────────────────────
const origin = window.location.origin;
document.querySelectorAll('.origin-span').forEach(el => el.textContent = origin);
document.getElementById('embed-code').textContent =
`<iframe src="${origin}/gcp.html" height="48" width="48" scrolling="no" frameborder="0"></iframe>`;
// ── copy toast ────────────────────────────────────────────────────────
function copyEmbed() {
const text = document.getElementById('embed-code').textContent;
navigator.clipboard?.writeText(text).catch(() => {});
const toast = document.getElementById('toast');
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 1800);
}
// ── countdown ─────────────────────────────────────────────────────────
let countdown = 0;
setInterval(() => {
if (countdown <= 0) {
update();
countdown = 60;
}
document.getElementById('tick').textContent = `Next update in ${countdown}s`;
countdown--;
}, 1000);
</script>
</body>
</html>