Add anonymised world map of active eggs
Server geolocates each egg by IP on first contact (ip-api.com), snaps coordinates to the nearest 1-degree grid (~111 km) and adds a small deterministic per-egg jitter so eggs in the same cell don't overlap. Only country + rounded lat/lon are stored; exact IPs are never saved. Front-end uses Leaflet with CartoDB dark tiles. Markers update on every poll cycle and are coloured to match the current coherence index. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
104
server/main.py
104
server/main.py
@@ -3,15 +3,18 @@ GCP Server — collects random trials from distributed eggs, runs Stouffer Z
|
|||||||
network variance analysis every 60 seconds, and serves the dot website.
|
network variance analysis every 60 seconds, and serves the dot website.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json as _json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import urllib.request
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from scipy import stats
|
from scipy import stats
|
||||||
@@ -47,6 +50,15 @@ def init_db():
|
|||||||
n_samples INTEGER NOT NULL,
|
n_samples INTEGER NOT NULL,
|
||||||
window_seconds 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
|
||||||
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
@@ -61,6 +73,60 @@ def db():
|
|||||||
con.close()
|
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
|
# Statistical analysis
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -171,9 +237,8 @@ def startup():
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/data")
|
@app.post("/api/data")
|
||||||
def receive_trial(data: TrialData):
|
def receive_trial(request: Request, data: TrialData):
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
# Reject data more than 5 minutes out of sync
|
|
||||||
if abs(data.timestamp - now) > 300:
|
if abs(data.timestamp - now) > 300:
|
||||||
raise HTTPException(status_code=422, detail="timestamp out of range")
|
raise HTTPException(status_code=422, detail="timestamp out of range")
|
||||||
with db() as con:
|
with db() as con:
|
||||||
@@ -181,6 +246,11 @@ def receive_trial(data: TrialData):
|
|||||||
"INSERT INTO trials (egg_id, timestamp, trial) VALUES (?, ?, ?)",
|
"INSERT INTO trials (egg_id, timestamp, trial) VALUES (?, ?, ?)",
|
||||||
(data.egg_id, data.timestamp, data.trial),
|
(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"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@@ -214,6 +284,34 @@ def get_history(limit: int = 60):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
@app.get("/api/eggs")
|
||||||
def get_active_eggs():
|
def get_active_eggs():
|
||||||
"""Return eggs that have submitted data in the last 2 minutes."""
|
"""Return eggs that have submitted data in the last 2 minutes."""
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Global Consciousness Project — Live Dot</title>
|
<title>Global Consciousness Project — Live Dot</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--dot-color: #00cc44;
|
--dot-color: #00cc44;
|
||||||
@@ -107,6 +108,41 @@
|
|||||||
margin: .5rem 0 2rem;
|
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 ── */
|
/* ── legend ── */
|
||||||
.card {
|
.card {
|
||||||
width: min(480px, 96vw);
|
width: min(480px, 96vw);
|
||||||
@@ -228,6 +264,12 @@
|
|||||||
|
|
||||||
<canvas id="chart"></canvas>
|
<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 -->
|
<!-- legend -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="card-title">What the color means</p>
|
<p class="card-title">What the color means</p>
|
||||||
@@ -278,6 +320,7 @@
|
|||||||
<p id="tick"></p>
|
<p id="tick"></p>
|
||||||
<div id="toast">Copied!</div>
|
<div id="toast">Copied!</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// ── color mapping ──────────────────────────────────────────────────────
|
// ── color mapping ──────────────────────────────────────────────────────
|
||||||
function palette(index) {
|
function palette(index) {
|
||||||
@@ -392,6 +435,7 @@
|
|||||||
try {
|
try {
|
||||||
const [status, history] = await Promise.all([fetchStatus(), fetchHistory()]);
|
const [status, history] = await Promise.all([fetchStatus(), fetchHistory()]);
|
||||||
|
|
||||||
|
currentIndex = status.index;
|
||||||
applyColor(status.index);
|
applyColor(status.index);
|
||||||
document.getElementById('status-label').innerHTML = label(status.index, status.n_eggs);
|
document.getElementById('status-label').innerHTML = label(status.index, status.n_eggs);
|
||||||
document.getElementById('s-index').textContent = Math.round(status.index) + '%';
|
document.getElementById('s-index').textContent = Math.round(status.index) + '%';
|
||||||
@@ -399,11 +443,56 @@
|
|||||||
document.getElementById('s-age').textContent = ageStr(status.timestamp);
|
document.getElementById('s-age').textContent = ageStr(status.timestamp);
|
||||||
|
|
||||||
drawChart(history.slice().reverse());
|
drawChart(history.slice().reverse());
|
||||||
|
updateMap();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('status-label').textContent = 'Connection error — retrying…';
|
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 ────────────────────────────────────────────────────────
|
// ── embed code ────────────────────────────────────────────────────────
|
||||||
const origin = window.location.origin;
|
const origin = window.location.origin;
|
||||||
document.querySelectorAll('.origin-span').forEach(el => el.textContent = origin);
|
document.querySelectorAll('.origin-span').forEach(el => el.textContent = origin);
|
||||||
|
|||||||
Reference in New Issue
Block a user