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