Initial commit — GCP Dot self-hosted clone
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:
4
.gitattributes
vendored
Normal file
4
.gitattributes
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
* text=auto
|
||||
Dockerfile text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.py text eol=lf
|
||||
81
.gitea/workflows/docker.yml
Normal file
81
.gitea/workflows/docker.yml
Normal 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
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
*.db
|
||||
.DS_Store
|
||||
109
README.md
Normal file
109
README.md
Normal 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 (0–200) 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 (40–90)** → normal random behavior → **green**
|
||||
|
||||
### Color table
|
||||
|
||||
| Color | Index | Meaning |
|
||||
|--------|-----------|---------|
|
||||
| Blue | > 95% | Significantly small variance — deep coherence |
|
||||
| Cyan | 90–95% | Small variance — probable coherence |
|
||||
| Green | 40–90% | Normal random behavior |
|
||||
| Yellow | 10–40% | Slightly elevated variance |
|
||||
| Orange | 5–10% | 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
13
docker-compose.yml
Normal 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
12
egg/Dockerfile
Normal 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
71
egg/egg.py
Normal 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
1
egg/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
requests==2.31.0
|
||||
15
server/Dockerfile
Normal file
15
server/Dockerfile
Normal 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
231
server/main.py
Normal 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
5
server/requirements.txt
Normal 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
51
server/static/gcp.html
Normal 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
434
server/static/index.html
Normal 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">> 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">< 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>
|
||||
Reference in New Issue
Block a user