remove server code (moved to private GCP-Server repo)
Some checks failed
Build & publish Docker images / Build & push all images (push) Failing after 2s
Some checks failed
Build & publish Docker images / Build & push all images (push) Failing after 2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +0,0 @@
|
|||||||
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"]
|
|
||||||
329
server/main.py
329
server/main.py
@@ -1,329 +0,0 @@
|
|||||||
"""
|
|
||||||
GCP Server — collects random trials from distributed eggs, runs Stouffer Z
|
|
||||||
network variance analysis every 60 seconds, and serves the dot website.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json as _json
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import urllib.request
|
|
||||||
from collections import defaultdict
|
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS egg_locations (
|
|
||||||
egg_id TEXT PRIMARY KEY,
|
|
||||||
country TEXT,
|
|
||||||
country_code TEXT,
|
|
||||||
lat REAL,
|
|
||||||
lon REAL,
|
|
||||||
located_at INTEGER
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def db():
|
|
||||||
con = sqlite3.connect(DB_PATH)
|
|
||||||
con.row_factory = sqlite3.Row
|
|
||||||
try:
|
|
||||||
yield con
|
|
||||||
con.commit()
|
|
||||||
finally:
|
|
||||||
con.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Geo-location (server-side, anonymised to 1° grid ≈ 111 km)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_geo_pending: set[str] = set()
|
|
||||||
_geo_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _jitter(egg_id: str) -> tuple[float, float]:
|
|
||||||
"""Deterministic ±0.35° offset so eggs in the same cell don't stack."""
|
|
||||||
h = int(hashlib.md5(egg_id.encode()).hexdigest()[:8], 16)
|
|
||||||
return ((h & 0xFFFF) / 0xFFFF - 0.5) * 0.7, ((h >> 16 & 0xFFFF) / 0xFFFF - 0.5) * 0.7
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_geo(egg_id: str, ip: str):
|
|
||||||
try:
|
|
||||||
url = f"http://ip-api.com/json/{ip}?fields=status,country,countryCode,lat,lon"
|
|
||||||
with urllib.request.urlopen(url, timeout=6) as resp:
|
|
||||||
data = _json.loads(resp.read())
|
|
||||||
if data.get("status") != "success":
|
|
||||||
return
|
|
||||||
lat = round(float(data["lat"])) + _jitter(egg_id)[0]
|
|
||||||
lon = round(float(data["lon"])) + _jitter(egg_id)[1]
|
|
||||||
with db() as con:
|
|
||||||
con.execute(
|
|
||||||
"INSERT OR REPLACE INTO egg_locations "
|
|
||||||
"(egg_id, country, country_code, lat, lon, located_at) "
|
|
||||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
||||||
(egg_id, data["country"], data["countryCode"],
|
|
||||||
round(lat, 2), round(lon, 2), int(time.time())),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[geo] {egg_id} {ip}: {exc}")
|
|
||||||
finally:
|
|
||||||
with _geo_lock:
|
|
||||||
_geo_pending.discard(egg_id)
|
|
||||||
|
|
||||||
|
|
||||||
def maybe_geolocate(egg_id: str, ip: str):
|
|
||||||
if not ip or ip in ("127.0.0.1", "::1"):
|
|
||||||
return
|
|
||||||
with _geo_lock:
|
|
||||||
if egg_id in _geo_pending:
|
|
||||||
return
|
|
||||||
with db() as con:
|
|
||||||
if con.execute(
|
|
||||||
"SELECT 1 FROM egg_locations WHERE egg_id = ?", (egg_id,)
|
|
||||||
).fetchone():
|
|
||||||
return
|
|
||||||
with _geo_lock:
|
|
||||||
_geo_pending.add(egg_id)
|
|
||||||
threading.Thread(target=_fetch_geo, args=(egg_id, ip), daemon=True).start()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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(request: Request, data: TrialData):
|
|
||||||
now = int(time.time())
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
client_ip = (
|
|
||||||
request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
|
||||||
or (request.client.host if request.client else "")
|
|
||||||
)
|
|
||||||
maybe_geolocate(data.egg_id, client_ip)
|
|
||||||
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/map")
|
|
||||||
def get_map():
|
|
||||||
"""Anonymised egg positions for the world map (active in last 5 min)."""
|
|
||||||
cutoff = int(time.time()) - 300
|
|
||||||
with db() as con:
|
|
||||||
rows = con.execute(
|
|
||||||
"""
|
|
||||||
SELECT el.egg_id, el.country, el.country_code, el.lat, el.lon,
|
|
||||||
MAX(t.timestamp) AS last_active
|
|
||||||
FROM egg_locations el
|
|
||||||
JOIN trials t ON el.egg_id = t.egg_id
|
|
||||||
WHERE t.timestamp > ?
|
|
||||||
GROUP BY el.egg_id
|
|
||||||
""",
|
|
||||||
(cutoff,),
|
|
||||||
).fetchall()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"country": r["country"],
|
|
||||||
"country_code": r["country_code"],
|
|
||||||
"lat": r["lat"],
|
|
||||||
"lon": r["lon"],
|
|
||||||
"last_active": r["last_active"],
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
fastapi==0.110.0
|
|
||||||
uvicorn[standard]==0.27.1
|
|
||||||
numpy==1.26.4
|
|
||||||
scipy==1.12.0
|
|
||||||
pydantic==2.6.3
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,523 +0,0 @@
|
|||||||
<!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>
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
||||||
<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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── map ── */
|
|
||||||
#map-wrap {
|
|
||||||
width: min(480px, 96vw);
|
|
||||||
background: #0e0e1c;
|
|
||||||
border: 1px solid #18182a;
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
#map-title {
|
|
||||||
font-size: .65rem;
|
|
||||||
letter-spacing: .14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #303050;
|
|
||||||
padding: 1rem 1.6rem .6rem;
|
|
||||||
}
|
|
||||||
#map {
|
|
||||||
height: 240px;
|
|
||||||
width: 100%;
|
|
||||||
background: #080812;
|
|
||||||
}
|
|
||||||
/* Override Leaflet chrome for dark theme */
|
|
||||||
.leaflet-container { background: #080812; }
|
|
||||||
.leaflet-control-attribution { display: none; }
|
|
||||||
.leaflet-tooltip {
|
|
||||||
background: #10101e;
|
|
||||||
border: 1px solid #202038;
|
|
||||||
color: #9090b8;
|
|
||||||
font-size: .75rem;
|
|
||||||
padding: .3rem .6rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.leaflet-tooltip-top::before { border-top-color: #202038; }
|
|
||||||
|
|
||||||
/* ── 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>
|
|
||||||
|
|
||||||
<!-- map -->
|
|
||||||
<div id="map-wrap">
|
|
||||||
<p id="map-title">Active eggs — anonymised to ~111 km</p>
|
|
||||||
<div id="map"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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 src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
||||||
<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()]);
|
|
||||||
|
|
||||||
currentIndex = status.index;
|
|
||||||
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());
|
|
||||||
updateMap();
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('status-label').textContent = 'Connection error — retrying…';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── map ───────────────────────────────────────────────────────────────
|
|
||||||
const leafletMap = L.map('map', {
|
|
||||||
center: [20, 10],
|
|
||||||
zoom: 1,
|
|
||||||
zoomControl: false,
|
|
||||||
scrollWheelZoom: false,
|
|
||||||
attributionControl: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', {
|
|
||||||
subdomains: 'abcd',
|
|
||||||
maxZoom: 6,
|
|
||||||
}).addTo(leafletMap);
|
|
||||||
|
|
||||||
let eggMarkers = [];
|
|
||||||
let currentIndex = 50;
|
|
||||||
|
|
||||||
async function updateMap() {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/map');
|
|
||||||
const eggs = await r.json();
|
|
||||||
eggMarkers.forEach(m => m.remove());
|
|
||||||
eggMarkers = [];
|
|
||||||
|
|
||||||
const [color] = palette(currentIndex);
|
|
||||||
|
|
||||||
eggs.forEach(egg => {
|
|
||||||
const m = L.circleMarker([egg.lat, egg.lon], {
|
|
||||||
radius: 7,
|
|
||||||
fillColor: color,
|
|
||||||
color: color,
|
|
||||||
fillOpacity: 0.75,
|
|
||||||
weight: 0,
|
|
||||||
}).addTo(leafletMap);
|
|
||||||
m.bindTooltip(egg.country || 'Unknown', { direction: 'top', offset: [0, -4] });
|
|
||||||
eggMarkers.push(m);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update egg count in map title
|
|
||||||
document.getElementById('map-title').textContent =
|
|
||||||
`Active eggs (${eggs.length}) — anonymised to ~111 km`;
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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