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.
|
||||
"""
|
||||
|
||||
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
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
from scipy import stats
|
||||
@@ -47,6 +50,15 @@ def init_db():
|
||||
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
|
||||
);
|
||||
""")
|
||||
|
||||
|
||||
@@ -61,6 +73,60 @@ def db():
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -171,9 +237,8 @@ def startup():
|
||||
|
||||
|
||||
@app.post("/api/data")
|
||||
def receive_trial(data: TrialData):
|
||||
def receive_trial(request: Request, 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:
|
||||
@@ -181,6 +246,11 @@ def receive_trial(data: TrialData):
|
||||
"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"}
|
||||
|
||||
|
||||
@@ -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")
|
||||
def get_active_eggs():
|
||||
"""Return eggs that have submitted data in the last 2 minutes."""
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<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;
|
||||
@@ -107,6 +108,41 @@
|
||||
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);
|
||||
@@ -228,6 +264,12 @@
|
||||
|
||||
<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>
|
||||
@@ -278,6 +320,7 @@
|
||||
<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) {
|
||||
@@ -392,6 +435,7 @@
|
||||
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) + '%';
|
||||
@@ -399,11 +443,56 @@
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user