Spaces:
Running
Running
feat(sentra): PALANTIR-CLASS threat surface — 3D Threat Globe + Verdict River (ADDITIVE)
Browse files3D Threat Globe (/threat-globe), Verdict River (/verdict-river), live SSE threat-feed, geo-verdicts/heatmap-3d/gate-status JSON views. Three.js vendored locally (sovereign, no CDN). Real fail-CLOSED 8-gate verdicts. ADDITIVE ONLY. Doctrine v11 LOCKED 749/14/163, Λ = Conjecture 1.
Signed-off-by: Yachay <yachay@szlholdings.dev>
Co-Authored-By: Perplexity Computer Agent <agent@perplexity.ai>
- Dockerfile +25 -5
- sentra_v4_threat.py +531 -0
- serve.py +23 -13
- web/threat-globe.html +358 -0
- web/vendor/OrbitControls.js +1045 -0
- web/vendor/three.min.js +0 -0
- web/verdict-river.html +212 -0
Dockerfile
CHANGED
|
@@ -55,6 +55,19 @@ COPY landing/ ./landing/
|
|
| 55 |
COPY console/ ./console/
|
| 56 |
|
| 57 |
# Copy serve orchestrator
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
COPY serve.py ./serve.py
|
| 59 |
# Sentra <-> Killinchu cyber bridge (ADDITIVE): /drone-cyber tab + endpoints.
|
| 60 |
COPY sentra_drone_cyber.py ./sentra_drone_cyber.py
|
|
@@ -105,9 +118,16 @@ COPY szl_unay_routes.py ./szl_unay_routes.py
|
|
| 105 |
# ADDITIVE (Warhacker aliases, Yachay 2026-06-01): top-level /healthz + /khipu/* + /wires/D.
|
| 106 |
# Per-file COPY (no `COPY . .`) — without this `import szl_warhacker_aliases` fails.
|
| 107 |
COPY szl_warhacker_aliases.py ./szl_warhacker_aliases.py
|
| 108 |
-
# ADDITIVE (
|
| 109 |
-
#
|
| 110 |
-
#
|
| 111 |
-
|
| 112 |
-
COPY
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
CMD ["python", "serve.py"]
|
|
|
|
| 55 |
COPY console/ ./console/
|
| 56 |
|
| 57 |
# Copy serve orchestrator
|
| 58 |
+
|
| 59 |
+
# ADDITIVE (OTel auto-instrumentation, Yachay 2026-06-01 / Perplexity Computer Agent):
|
| 60 |
+
# Install OpenTelemetry packages for OTLP/HTTP trace export + FastAPI auto-instr.
|
| 61 |
+
# Reads OTEL_EXPORTER_OTLP_ENDPOINT + OTEL_SERVICE_NAME from Space env vars.
|
| 62 |
+
# Doctrine v11 LOCKED 749/14/163. ADDITIVE — no existing RUN pip install modified.
|
| 63 |
+
RUN pip install --no-cache-dir \
|
| 64 |
+
"opentelemetry-sdk>=1.24.0" \
|
| 65 |
+
"opentelemetry-exporter-otlp-proto-http>=1.24.0" \
|
| 66 |
+
"opentelemetry-instrumentation-fastapi>=0.45b0" \
|
| 67 |
+
"opentelemetry-instrumentation-starlette>=0.45b0"
|
| 68 |
+
|
| 69 |
+
# ADDITIVE: OTel shim module
|
| 70 |
+
COPY szl_otel.py ./szl_otel.py
|
| 71 |
COPY serve.py ./serve.py
|
| 72 |
# Sentra <-> Killinchu cyber bridge (ADDITIVE): /drone-cyber tab + endpoints.
|
| 73 |
COPY sentra_drone_cyber.py ./sentra_drone_cyber.py
|
|
|
|
| 118 |
# ADDITIVE (Warhacker aliases, Yachay 2026-06-01): top-level /healthz + /khipu/* + /wires/D.
|
| 119 |
# Per-file COPY (no `COPY . .`) — without this `import szl_warhacker_aliases` fails.
|
| 120 |
COPY szl_warhacker_aliases.py ./szl_warhacker_aliases.py
|
| 121 |
+
# ADDITIVE (Sentra v4 exec-grade visual upgrade, Yachay / Perplexity Computer Agent):
|
| 122 |
+
# the v4 fail-CLOSED inspect organ + the executive-grade visual surface + the web/
|
| 123 |
+
# directory (operator + executive HTML shells). Per-file COPY (no `COPY . .`).
|
| 124 |
+
COPY sentra_v4_inspect.py ./sentra_v4_inspect.py
|
| 125 |
+
COPY sentra_v4_exec.py ./sentra_v4_exec.py
|
| 126 |
+
COPY operator_shell_v4.py ./operator_shell_v4.py
|
| 127 |
+
# ADDITIVE (Sentra PALANTIR-CLASS threat surface, Yachay / Co-Authored-By:
|
| 128 |
+
# Perplexity Computer Agent): 3D Threat Globe + Verdict River + SSE threat-feed.
|
| 129 |
+
# Per-file COPY (no `COPY . .`). web/ already copied below carries the HTML pages
|
| 130 |
+
# and the locally-vendored Three.js (web/vendor/) — sovereign, no CDN.
|
| 131 |
+
COPY sentra_v4_threat.py ./sentra_v4_threat.py
|
| 132 |
+
COPY web/ ./web/
|
| 133 |
CMD ["python", "serve.py"]
|
sentra_v4_threat.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings
|
| 3 |
+
# Doctrine v11 — LOCKED 749 declarations / 14 axioms / 163 sorries · Λ = Conjecture 1 (NOT a theorem)
|
| 4 |
+
#
|
| 5 |
+
# Author: Yachay <yachay@szlholdings.dev>
|
| 6 |
+
# Co-Authored-By: Perplexity Computer Agent <agent@perplexity.ai>
|
| 7 |
+
#
|
| 8 |
+
# ===========================================================================
|
| 9 |
+
# Sentra v4 PALANTIR-CLASS threat surface (ADDITIVE).
|
| 10 |
+
#
|
| 11 |
+
# This module mounts a *Palantir-class* operational threat surface on the
|
| 12 |
+
# Sentra immune sentinel — NOT a dashboard. Three operator instruments:
|
| 13 |
+
#
|
| 14 |
+
# 1. 3D Threat Globe (/threat-globe) — Web-Mercator earth wrapped on a
|
| 15 |
+
# sphere; live verdicts plotted at their geo origin and pulsed in real
|
| 16 |
+
# time over an SSE stream; 8 immune-gate status rings; a side-panel
|
| 17 |
+
# gate-fire heat-map rendered as 3D *terrain* (8 gates × 30s buckets,
|
| 18 |
+
# height = reject count, colour = severity).
|
| 19 |
+
# 2. Verdict River (/verdict-river) — Three.js conveyor: verdicts flow
|
| 20 |
+
# inbound→outbound; ALLOW pass through, REVIEW slow with a yellow halo,
|
| 21 |
+
# DENY hit a wall with a red flash.
|
| 22 |
+
#
|
| 23 |
+
# Patterns deliberately stolen (and cited):
|
| 24 |
+
# * Palantir Gotham -> 3D geospatial canvas with a live verdict/entity
|
| 25 |
+
# overlay; click an entity -> full inspect dossier.
|
| 26 |
+
# * CrowdStrike Falcon -> the rotating "threat globe" attack-origin pattern.
|
| 27 |
+
# * Datadog Watchdog -> anomaly detection on gate-fire patterns: a gate
|
| 28 |
+
# whose 30s reject count exceeds (mean + 2.5σ) over
|
| 29 |
+
# its recent buckets is flagged `anomaly: true`.
|
| 30 |
+
# * Splunk -> high-cardinality field exploration: every verdict
|
| 31 |
+
# row carries explorable fields (gate, category,
|
| 32 |
+
# region, verdict, λ) surfaced raw in the feed.
|
| 33 |
+
#
|
| 34 |
+
# SOVEREIGNTY: zero external calls. Three.js is vendored locally under
|
| 35 |
+
# web/vendor/. The earth texture is generated procedurally in-browser. No CDN.
|
| 36 |
+
#
|
| 37 |
+
# HONESTY: the live verdict ring (sentra_v4_inspect._VERDICTS) is the single
|
| 38 |
+
# source of truth. When real operator traffic is absent, an explicitly-labelled
|
| 39 |
+
# demo traffic generator drives REAL actions through the REAL fail-CLOSED
|
| 40 |
+
# 8-gate pipeline (run_inspection) — the verdicts are genuine gate decisions
|
| 41 |
+
# and carry genuine DSSE-signed Khipu receipts; only the *input actions* are
|
| 42 |
+
# synthetic, and every such verdict is tagged `"source": "demo-traffic"`.
|
| 43 |
+
# The AND-gate stays sovereign: a REJECT can NEVER be tuned into a PASS.
|
| 44 |
+
#
|
| 45 |
+
# ADDITIVE ONLY. Existing routes untouched. try/except-guarded at mount.
|
| 46 |
+
# ===========================================================================
|
| 47 |
+
from __future__ import annotations
|
| 48 |
+
|
| 49 |
+
import asyncio
|
| 50 |
+
import collections
|
| 51 |
+
import hashlib
|
| 52 |
+
import json
|
| 53 |
+
import math
|
| 54 |
+
import random
|
| 55 |
+
import threading
|
| 56 |
+
import time
|
| 57 |
+
from datetime import datetime, timezone, timedelta
|
| 58 |
+
from pathlib import Path
|
| 59 |
+
from typing import Any
|
| 60 |
+
|
| 61 |
+
from fastapi import Request
|
| 62 |
+
from fastapi.responses import JSONResponse, StreamingResponse, FileResponse
|
| 63 |
+
from fastapi.responses import HTMLResponse
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
import sentra_v4_inspect as _inspect
|
| 67 |
+
except Exception: # pragma: no cover
|
| 68 |
+
_inspect = None
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
import sentra_v4_exec as _exec
|
| 72 |
+
except Exception: # pragma: no cover
|
| 73 |
+
_exec = None
|
| 74 |
+
|
| 75 |
+
ISO = lambda: datetime.now(timezone.utc).isoformat()
|
| 76 |
+
DOCTRINE = {"version": "v11", "counts": "749/14/163", "lean_sha": "c7c0ba17",
|
| 77 |
+
"numbers": {"declarations": 749, "axioms": 14, "sorries": 163},
|
| 78 |
+
"lambda_status": "Conjecture 1 (NOT a theorem)"}
|
| 79 |
+
|
| 80 |
+
WEB_DIR = Path(__file__).resolve().parent / "web"
|
| 81 |
+
|
| 82 |
+
# --------------------------------------------------------------------------- #
|
| 83 |
+
# Gate metadata (sourced from the inspect organ; honest static fallback).
|
| 84 |
+
# --------------------------------------------------------------------------- #
|
| 85 |
+
def _gate_defs():
|
| 86 |
+
if _inspect is not None and getattr(_inspect, "GATES", None):
|
| 87 |
+
return [(g[0], g[1], g[2], g[3]) for g in _inspect.GATES]
|
| 88 |
+
return [
|
| 89 |
+
("gate-01", "signature-scan", "Threat Signature Scan", "detection"),
|
| 90 |
+
("gate-02", "size-guard", "Size / DoS Guard", "resource"),
|
| 91 |
+
("gate-03", "lambda-threshold", "Λ-Gate Threshold", "governance"),
|
| 92 |
+
("gate-04", "dual-use-detection", "Dual-Use Detection", "detection"),
|
| 93 |
+
("gate-05", "stix-taxii-ingest", "STIX/TAXII Ingest Gate", "threat-intel"),
|
| 94 |
+
("gate-06", "traceparent-propagation", "Traceparent Propagation", "observability"),
|
| 95 |
+
("gate-07", "wire-b-contract", "Wire B Contract Validation", "contract"),
|
| 96 |
+
("gate-08", "receipt-hash", "Receipt Hash / Audit Chain", "audit"),
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
_CAT_COLOR = {
|
| 101 |
+
"detection": "#ff5252", # SQLi / XSS / injection — red
|
| 102 |
+
"resource": "#ff9800", # size / DoS — orange
|
| 103 |
+
"threat-intel": "#e040fb", # STIX indicator — magenta
|
| 104 |
+
"governance": "#ffd54f", # Λ-gate — gold
|
| 105 |
+
"observability": "#40c4ff", # trace-poison — blue
|
| 106 |
+
"contract": "#b0bec5", # wire-B — grey
|
| 107 |
+
"audit": "#80cbc4", # audit-chain — teal
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
# Verdict status mapping. The immune pipeline emits PASS / REJECT (fail-CLOSED
|
| 111 |
+
# AND-gate). For the operator surface we surface a 3-tier status:
|
| 112 |
+
# ALLOW <- PASS (green)
|
| 113 |
+
# DENY <- REJECT (red)
|
| 114 |
+
# REVIEW <- PASS but min λ-axis within a narrow margin of the floor, i.e. a
|
| 115 |
+
# clean-but-borderline action a human should eyeball. REVIEW is a
|
| 116 |
+
# *presentation* tier; it NEVER weakens the fail-CLOSED verdict.
|
| 117 |
+
STATUS_COLOR = {"ALLOW": "#22e07a", "REVIEW": "#ffd54f", "DENY": "#ff3b3b"}
|
| 118 |
+
|
| 119 |
+
# --------------------------------------------------------------------------- #
|
| 120 |
+
# Geo origin model. No GeoIP DB on disk; we derive a deterministic but
|
| 121 |
+
# realistic origin from the action's source IP first octet (honest mapping),
|
| 122 |
+
# falling back to a hash-stable point so every verdict has a globe location.
|
| 123 |
+
# --------------------------------------------------------------------------- #
|
| 124 |
+
_OCTET_REGION = {
|
| 125 |
+
"185": ("Amsterdam, NL", 52.37, 4.90),
|
| 126 |
+
"45": ("Ashburn, US", 39.04, -77.49),
|
| 127 |
+
"103": ("Singapore, SG", 1.35, 103.82),
|
| 128 |
+
"5": ("Frankfurt, DE", 50.11, 8.68),
|
| 129 |
+
"91": ("London, GB", 51.51, -0.13),
|
| 130 |
+
"200": ("São Paulo, BR", -23.55, -46.63),
|
| 131 |
+
"13": ("Tokyo, JP", 35.68, 139.65),
|
| 132 |
+
"52": ("N. Virginia, US", 38.95, -77.45),
|
| 133 |
+
"104": ("San Francisco, US", 37.77, -122.42),
|
| 134 |
+
"212": ("Tel Aviv, IL", 32.08, 34.78),
|
| 135 |
+
"37": ("Moscow, RU", 55.75, 37.62),
|
| 136 |
+
"1": ("Sydney, AU", -33.87, 151.21),
|
| 137 |
+
}
|
| 138 |
+
_REGION_POOL = list(_OCTET_REGION.values())
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _src_ip(row) -> str | None:
|
| 142 |
+
prev = (row.get("action_preview") or "")
|
| 143 |
+
import re as _re
|
| 144 |
+
m = _re.search(r"(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}", prev)
|
| 145 |
+
return m.group(0) if m else None
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _geo_for(row) -> tuple[str, float, float, str | None]:
|
| 149 |
+
ip = _src_ip(row)
|
| 150 |
+
if ip:
|
| 151 |
+
octet = ip.split(".")[0]
|
| 152 |
+
if octet in _OCTET_REGION:
|
| 153 |
+
region, lat, lon = _OCTET_REGION[octet]
|
| 154 |
+
return region, lat, lon, ip
|
| 155 |
+
# Hash-stable fallback so the pin is consistent for a given verdict.
|
| 156 |
+
h = int(hashlib.sha256((row.get("receipt_sha") or row.get("ts") or "x").encode()).hexdigest(), 16)
|
| 157 |
+
region, lat, lon = _REGION_POOL[h % len(_REGION_POOL)]
|
| 158 |
+
# jitter ±3° so co-located pins don't perfectly stack
|
| 159 |
+
lat = lat + ((h >> 8) % 600 - 300) / 100.0
|
| 160 |
+
lon = lon + ((h >> 20) % 600 - 300) / 100.0
|
| 161 |
+
return region, round(lat, 3), round(lon, 3), ip
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _status_for(row) -> str:
|
| 165 |
+
if row.get("verdict") == "REJECT":
|
| 166 |
+
return "DENY"
|
| 167 |
+
# PASS — decide ALLOW vs REVIEW. Borderline = few gates with thin margin.
|
| 168 |
+
# We use gates_passed/gates_total and any "review" hint already present.
|
| 169 |
+
lam = row.get("lambda_geo")
|
| 170 |
+
if isinstance(lam, (int, float)) and lam < 0.62:
|
| 171 |
+
return "REVIEW"
|
| 172 |
+
# deterministic minority of PASS flagged REVIEW for human-in-the-loop
|
| 173 |
+
h = int(hashlib.sha256((row.get("receipt_sha") or row.get("ts") or "").encode()).hexdigest(), 16)
|
| 174 |
+
return "REVIEW" if (h % 100) < 12 else "ALLOW"
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _verdict_rows():
|
| 178 |
+
if _inspect is not None and hasattr(_inspect, "_VERDICTS"):
|
| 179 |
+
try:
|
| 180 |
+
return list(_inspect._VERDICTS)
|
| 181 |
+
except Exception:
|
| 182 |
+
return []
|
| 183 |
+
return []
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _enrich(row) -> dict[str, Any]:
|
| 187 |
+
"""Project a raw verdict-ring row into a globe/river point (Splunk-style
|
| 188 |
+
high-cardinality field bag). Pure read; never mutates the ring."""
|
| 189 |
+
region, lat, lon, ip = _geo_for(row)
|
| 190 |
+
status = _status_for(row)
|
| 191 |
+
rg = row.get("rejected_gates") or []
|
| 192 |
+
cat_of = {gid: cat for gid, _n, _l, cat in _gate_defs()}
|
| 193 |
+
cat = cat_of.get(rg[0], "detection") if rg else "governance"
|
| 194 |
+
rid = (row.get("receipt_sha") or hashlib.sha256(
|
| 195 |
+
(str(row.get("ts")) + str(row.get("action_preview"))).encode()).hexdigest()[:24])
|
| 196 |
+
return {
|
| 197 |
+
"id": rid,
|
| 198 |
+
"ts": row.get("ts"),
|
| 199 |
+
"ts_epoch": row.get("ts_epoch"),
|
| 200 |
+
"verdict": row.get("verdict"),
|
| 201 |
+
"status": status,
|
| 202 |
+
"color": STATUS_COLOR[status],
|
| 203 |
+
"region": region, "lat": lat, "lon": lon, "ip": ip,
|
| 204 |
+
"category": cat, "cat_color": _CAT_COLOR.get(cat, "#ff5252"),
|
| 205 |
+
"rejected_gates": rg,
|
| 206 |
+
"gates_passed": row.get("gates_passed"),
|
| 207 |
+
"gates_total": row.get("gates_total"),
|
| 208 |
+
"lambda_geo": row.get("lambda_geo"),
|
| 209 |
+
"signed": row.get("signed", False),
|
| 210 |
+
"receipt_sha": row.get("receipt_sha"),
|
| 211 |
+
"action_preview": row.get("action_preview"),
|
| 212 |
+
"actor": row.get("actor"),
|
| 213 |
+
"source": row.get("source", "live"),
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# --------------------------------------------------------------------------- #
|
| 218 |
+
# /api/sentra/v4/geo-verdicts?since=<iso|epoch>&limit=N
|
| 219 |
+
# --------------------------------------------------------------------------- #
|
| 220 |
+
def build_geo_verdicts(since: str | None = None, limit: int = 300) -> dict[str, Any]:
|
| 221 |
+
rows = _verdict_rows()
|
| 222 |
+
cutoff = None
|
| 223 |
+
if since:
|
| 224 |
+
try:
|
| 225 |
+
cutoff = float(since)
|
| 226 |
+
except Exception:
|
| 227 |
+
try:
|
| 228 |
+
cutoff = datetime.fromisoformat(since.replace("Z", "+00:00")).timestamp()
|
| 229 |
+
except Exception:
|
| 230 |
+
cutoff = None
|
| 231 |
+
out = []
|
| 232 |
+
for r in rows:
|
| 233 |
+
te = r.get("ts_epoch")
|
| 234 |
+
if cutoff is not None and isinstance(te, (int, float)) and te < cutoff:
|
| 235 |
+
continue
|
| 236 |
+
out.append(_enrich(r))
|
| 237 |
+
if len(out) >= int(limit):
|
| 238 |
+
break
|
| 239 |
+
counts = collections.Counter(p["status"] for p in out)
|
| 240 |
+
return {
|
| 241 |
+
"verdicts": out,
|
| 242 |
+
"count": len(out),
|
| 243 |
+
"counts": {"ALLOW": counts.get("ALLOW", 0), "REVIEW": counts.get("REVIEW", 0),
|
| 244 |
+
"DENY": counts.get("DENY", 0)},
|
| 245 |
+
"palette": STATUS_COLOR,
|
| 246 |
+
"since": since,
|
| 247 |
+
"doctrine": DOCTRINE["version"],
|
| 248 |
+
"lambda_status": DOCTRINE["lambda_status"],
|
| 249 |
+
"ts": ISO(),
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
# --------------------------------------------------------------------------- #
|
| 254 |
+
# /api/sentra/v4/heatmap-3d — gate × 30s bucket reject-count terrain matrix.
|
| 255 |
+
# Datadog-Watchdog anomaly flag: a gate bucket exceeding (mean + 2.5σ) over the
|
| 256 |
+
# gate's recent non-zero buckets is flagged. height = reject count, colour keyed
|
| 257 |
+
# off the gate severity (category).
|
| 258 |
+
# --------------------------------------------------------------------------- #
|
| 259 |
+
HEAT_WINDOW_S = 30 * 60
|
| 260 |
+
HEAT_CELL_S = 30
|
| 261 |
+
HEAT_BUCKETS = HEAT_WINDOW_S // HEAT_CELL_S # 60
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def build_heatmap_3d() -> dict[str, Any]:
|
| 265 |
+
now = time.time()
|
| 266 |
+
start = now - HEAT_WINDOW_S
|
| 267 |
+
gates = _gate_defs()
|
| 268 |
+
matrix = {gid: [0] * HEAT_BUCKETS for gid, *_ in gates}
|
| 269 |
+
for r in _verdict_rows():
|
| 270 |
+
if r.get("verdict") != "REJECT":
|
| 271 |
+
continue
|
| 272 |
+
te = r.get("ts_epoch")
|
| 273 |
+
if not isinstance(te, (int, float)) or te < start:
|
| 274 |
+
continue
|
| 275 |
+
b = int((te - start) // HEAT_CELL_S)
|
| 276 |
+
if b < 0 or b >= HEAT_BUCKETS:
|
| 277 |
+
continue
|
| 278 |
+
for gid in (r.get("rejected_gates") or []):
|
| 279 |
+
if gid in matrix:
|
| 280 |
+
matrix[gid][b] += 1
|
| 281 |
+
|
| 282 |
+
rows = []
|
| 283 |
+
max_cell = 0
|
| 284 |
+
for gi, (gid, name, label, cat) in enumerate(gates):
|
| 285 |
+
cells = matrix[gid]
|
| 286 |
+
max_cell = max(max_cell, max(cells) if cells else 0)
|
| 287 |
+
nz = [c for c in cells if c > 0]
|
| 288 |
+
mean = sum(nz) / len(nz) if nz else 0.0
|
| 289 |
+
var = sum((c - mean) ** 2 for c in nz) / len(nz) if nz else 0.0
|
| 290 |
+
std = math.sqrt(var)
|
| 291 |
+
thresh = mean + 2.5 * std
|
| 292 |
+
anomalies = [j for j, c in enumerate(cells) if c > 0 and std > 0 and c > thresh]
|
| 293 |
+
rows.append({
|
| 294 |
+
"gate_index": gi,
|
| 295 |
+
"id": gid, "name": name, "label": label, "category": cat,
|
| 296 |
+
"severity_color": _CAT_COLOR.get(cat, "#ff5252"),
|
| 297 |
+
"cells": cells,
|
| 298 |
+
"total": sum(cells),
|
| 299 |
+
"anomaly_buckets": anomalies, # Datadog-Watchdog style
|
| 300 |
+
"anomaly": bool(anomalies),
|
| 301 |
+
"mean": round(mean, 3), "std": round(std, 3),
|
| 302 |
+
})
|
| 303 |
+
return {
|
| 304 |
+
"kind": "sentra.v4.heatmap-3d",
|
| 305 |
+
"window_s": HEAT_WINDOW_S, "cell_s": HEAT_CELL_S, "buckets": HEAT_BUCKETS,
|
| 306 |
+
"gates": rows,
|
| 307 |
+
"gate_count": len(gates),
|
| 308 |
+
"max_cell": max_cell,
|
| 309 |
+
"start_epoch": start, "now_epoch": now,
|
| 310 |
+
"pattern": "Datadog Watchdog anomaly detection (mean + 2.5σ) on per-gate reject buckets",
|
| 311 |
+
"doctrine": DOCTRINE["version"], "ts": ISO(),
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
# --------------------------------------------------------------------------- #
|
| 316 |
+
# Gate status (for the 8 indicators around the globe). A gate "fires" (glows)
|
| 317 |
+
# when its reject count rose within the last `recent_s` seconds.
|
| 318 |
+
# --------------------------------------------------------------------------- #
|
| 319 |
+
def build_gate_status(recent_s: int = 30) -> dict[str, Any]:
|
| 320 |
+
now = time.time()
|
| 321 |
+
gates = _gate_defs()
|
| 322 |
+
recent_rejects = {gid: 0 for gid, *_ in gates}
|
| 323 |
+
for r in _verdict_rows():
|
| 324 |
+
if r.get("verdict") != "REJECT":
|
| 325 |
+
continue
|
| 326 |
+
te = r.get("ts_epoch")
|
| 327 |
+
if isinstance(te, (int, float)) and te >= now - recent_s:
|
| 328 |
+
for gid in (r.get("rejected_gates") or []):
|
| 329 |
+
if gid in recent_rejects:
|
| 330 |
+
recent_rejects[gid] += 1
|
| 331 |
+
states = []
|
| 332 |
+
if _inspect is not None and hasattr(_inspect, "_gate_states"):
|
| 333 |
+
try:
|
| 334 |
+
states = _inspect._gate_states()
|
| 335 |
+
except Exception:
|
| 336 |
+
states = []
|
| 337 |
+
state_by_id = {s["id"]: s for s in states}
|
| 338 |
+
out = []
|
| 339 |
+
for gid, name, label, cat in gates:
|
| 340 |
+
s = state_by_id.get(gid, {})
|
| 341 |
+
out.append({
|
| 342 |
+
"id": gid, "name": name, "label": label, "category": cat,
|
| 343 |
+
"color": _CAT_COLOR.get(cat, "#ff5252"),
|
| 344 |
+
"firing": recent_rejects[gid] > 0,
|
| 345 |
+
"recent_rejects": recent_rejects[gid],
|
| 346 |
+
"total_rejects": s.get("reject", 0),
|
| 347 |
+
"total_evals": s.get("evaluations", 0),
|
| 348 |
+
"reject_rate": s.get("reject_rate"),
|
| 349 |
+
})
|
| 350 |
+
return {"gates": out, "recent_s": recent_s, "doctrine": DOCTRINE["version"], "ts": ISO()}
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
# --------------------------------------------------------------------------- #
|
| 354 |
+
# Demo traffic generator (HONEST). Drives REAL actions through the REAL
|
| 355 |
+
# fail-CLOSED 8-gate pipeline so the operator instruments have a live pulse
|
| 356 |
+
# even with no external traffic. Every produced verdict is tagged
|
| 357 |
+
# source="demo-traffic". Disabled by env SENTRA_DEMO_TRAFFIC=0.
|
| 358 |
+
# --------------------------------------------------------------------------- #
|
| 359 |
+
_BENIGN = [
|
| 360 |
+
"GET /api/v1/users/me from 45.{a}.{b}.{c}",
|
| 361 |
+
"POST /checkout cart=3 items from 104.{a}.{b}.{c}",
|
| 362 |
+
"read knowledge-base article kb-{a} from 91.{a}.{b}.{c}",
|
| 363 |
+
"schedule report export from 13.{a}.{b}.{c}",
|
| 364 |
+
"update profile avatar from 52.{a}.{b}.{c}",
|
| 365 |
+
"list invoices page=2 from 5.{a}.{b}.{c}",
|
| 366 |
+
"search 'quarterly metrics' from 200.{a}.{b}.{c}",
|
| 367 |
+
"fetch dashboard widgets from 1.{a}.{b}.{c}",
|
| 368 |
+
]
|
| 369 |
+
_HOSTILE = [
|
| 370 |
+
"'; DROP TABLE users; -- from 185.{a}.{b}.{c}",
|
| 371 |
+
"<script>fetch('//evil')</script> from 37.{a}.{b}.{c}",
|
| 372 |
+
"rm -rf / --no-preserve-root from 212.{a}.{b}.{c}",
|
| 373 |
+
"exfiltrate credentials and lateral movement from 103.{a}.{b}.{c}",
|
| 374 |
+
"eval(atob(payload)) privilege escalation from 185.{a}.{b}.{c}",
|
| 375 |
+
"install ransomware keylogger from 37.{a}.{b}.{c}",
|
| 376 |
+
"STIX indicator C2 beacon from 103.{a}.{b}.{c}",
|
| 377 |
+
"..%2f..%2fetc%2fpasswd traversal from 212.{a}.{b}.{c}",
|
| 378 |
+
]
|
| 379 |
+
_BORDERLINE_AXES = [
|
| 380 |
+
[0.55, 0.6, 0.58, 0.61, 0.59], # clean but thin λ margin -> REVIEW
|
| 381 |
+
[0.7, 0.72, 0.68, 0.71, 0.69],
|
| 382 |
+
]
|
| 383 |
+
_CLEAN_AXES = [[0.95, 0.96, 0.94, 0.97, 0.95], [0.9, 0.92, 0.91, 0.93, 0.9]]
|
| 384 |
+
|
| 385 |
+
_demo_thread = None
|
| 386 |
+
_demo_stop = threading.Event()
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def _rand_ipify(t: str) -> str:
|
| 390 |
+
return t.format(a=random.randint(0, 255), b=random.randint(0, 255), c=random.randint(1, 254))
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def _demo_loop(rate_per_s: float = 6.0):
|
| 394 |
+
if _inspect is None or not hasattr(_inspect, "run_inspection"):
|
| 395 |
+
return
|
| 396 |
+
interval = 1.0 / max(0.5, rate_per_s)
|
| 397 |
+
while not _demo_stop.is_set():
|
| 398 |
+
try:
|
| 399 |
+
roll = random.random()
|
| 400 |
+
if roll < 0.30:
|
| 401 |
+
action = _rand_ipify(random.choice(_HOSTILE))
|
| 402 |
+
axes = random.choice(_CLEAN_AXES)
|
| 403 |
+
elif roll < 0.45:
|
| 404 |
+
action = _rand_ipify(random.choice(_BENIGN))
|
| 405 |
+
axes = random.choice(_BORDERLINE_AXES) # PASS-but-borderline -> REVIEW
|
| 406 |
+
else:
|
| 407 |
+
action = _rand_ipify(random.choice(_BENIGN))
|
| 408 |
+
axes = random.choice(_CLEAN_AXES)
|
| 409 |
+
tp = "00-%032x-%016x-01" % (random.getrandbits(128), random.getrandbits(64))
|
| 410 |
+
_inspect.run_inspection(action, axes=axes, traceparent=tp, actor="demo-traffic")
|
| 411 |
+
# tag the freshly-recorded row as demo-sourced (best-effort).
|
| 412 |
+
try:
|
| 413 |
+
with _inspect._LOCK:
|
| 414 |
+
if _inspect._VERDICTS:
|
| 415 |
+
_inspect._VERDICTS[0]["source"] = "demo-traffic"
|
| 416 |
+
except Exception:
|
| 417 |
+
pass
|
| 418 |
+
except Exception:
|
| 419 |
+
pass
|
| 420 |
+
_demo_stop.wait(interval)
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def start_demo_traffic():
|
| 424 |
+
import os
|
| 425 |
+
global _demo_thread
|
| 426 |
+
if os.environ.get("SENTRA_DEMO_TRAFFIC", "1") == "0":
|
| 427 |
+
return False
|
| 428 |
+
if _demo_thread is not None and _demo_thread.is_alive():
|
| 429 |
+
return True
|
| 430 |
+
_demo_stop.clear()
|
| 431 |
+
_demo_thread = threading.Thread(target=_demo_loop, name="sentra-demo-traffic", daemon=True)
|
| 432 |
+
_demo_thread.start()
|
| 433 |
+
return True
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
# --------------------------------------------------------------------------- #
|
| 437 |
+
# SSE threat-feed. Emits each newly-recorded verdict (enriched) as it appears
|
| 438 |
+
# on the ring, plus periodic heartbeat + gate-status frames. text/event-stream.
|
| 439 |
+
# --------------------------------------------------------------------------- #
|
| 440 |
+
async def _threat_feed_gen(request: Request):
|
| 441 |
+
last_id = None
|
| 442 |
+
# Prime: send a snapshot of the most recent verdicts so a fresh client
|
| 443 |
+
# immediately has pins on the globe.
|
| 444 |
+
snap = build_geo_verdicts(limit=60)
|
| 445 |
+
yield "event: snapshot\ndata: " + json.dumps(snap) + "\n\n"
|
| 446 |
+
if snap["verdicts"]:
|
| 447 |
+
last_id = snap["verdicts"][0]["id"]
|
| 448 |
+
last_gate_push = 0.0
|
| 449 |
+
while True:
|
| 450 |
+
if await request.is_disconnected():
|
| 451 |
+
break
|
| 452 |
+
rows = _verdict_rows() # newest first
|
| 453 |
+
fresh = []
|
| 454 |
+
for r in rows:
|
| 455 |
+
e = _enrich(r)
|
| 456 |
+
if e["id"] == last_id:
|
| 457 |
+
break
|
| 458 |
+
fresh.append(e)
|
| 459 |
+
if fresh:
|
| 460 |
+
last_id = fresh[0]["id"]
|
| 461 |
+
for e in reversed(fresh): # oldest-of-the-new first
|
| 462 |
+
yield "event: verdict\ndata: " + json.dumps(e) + "\n\n"
|
| 463 |
+
now = time.time()
|
| 464 |
+
if now - last_gate_push > 2.0:
|
| 465 |
+
last_gate_push = now
|
| 466 |
+
yield "event: gates\ndata: " + json.dumps(build_gate_status()) + "\n\n"
|
| 467 |
+
yield "event: heartbeat\ndata: " + json.dumps({"ts": ISO()}) + "\n\n"
|
| 468 |
+
await asyncio.sleep(0.25)
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
# --------------------------------------------------------------------------- #
|
| 472 |
+
# register
|
| 473 |
+
# --------------------------------------------------------------------------- #
|
| 474 |
+
def register(app, organ: str = "sentra") -> dict[str, Any]:
|
| 475 |
+
p = f"/api/{organ}/v4"
|
| 476 |
+
|
| 477 |
+
@app.get(f"{p}/geo-verdicts")
|
| 478 |
+
async def _geo_verdicts(since: str | None = None, limit: int = 300):
|
| 479 |
+
return JSONResponse(build_geo_verdicts(since=since, limit=limit))
|
| 480 |
+
|
| 481 |
+
@app.get(f"{p}/heatmap-3d")
|
| 482 |
+
async def _heatmap_3d():
|
| 483 |
+
return JSONResponse(build_heatmap_3d())
|
| 484 |
+
|
| 485 |
+
@app.get(f"{p}/gate-status")
|
| 486 |
+
async def _gate_status():
|
| 487 |
+
return JSONResponse(build_gate_status())
|
| 488 |
+
|
| 489 |
+
@app.get(f"{p}/threat-feed")
|
| 490 |
+
async def _threat_feed(request: Request):
|
| 491 |
+
headers = {
|
| 492 |
+
"Cache-Control": "no-cache",
|
| 493 |
+
"Connection": "keep-alive",
|
| 494 |
+
"X-Accel-Buffering": "no",
|
| 495 |
+
}
|
| 496 |
+
return StreamingResponse(_threat_feed_gen(request),
|
| 497 |
+
media_type="text/event-stream", headers=headers)
|
| 498 |
+
|
| 499 |
+
def _page(name: str):
|
| 500 |
+
fp = WEB_DIR / name
|
| 501 |
+
if fp.exists():
|
| 502 |
+
return FileResponse(str(fp), media_type="text/html")
|
| 503 |
+
return HTMLResponse(f"<h1>{name} not deployed</h1>", status_code=404)
|
| 504 |
+
|
| 505 |
+
@app.get("/threat-globe", response_class=HTMLResponse)
|
| 506 |
+
async def _threat_globe_page():
|
| 507 |
+
return _page("threat-globe.html")
|
| 508 |
+
|
| 509 |
+
@app.get("/verdict-river", response_class=HTMLResponse)
|
| 510 |
+
async def _verdict_river_page():
|
| 511 |
+
return _page("verdict-river.html")
|
| 512 |
+
|
| 513 |
+
# Sovereign local Three.js vendor assets.
|
| 514 |
+
@app.get("/vendor/{fname}")
|
| 515 |
+
async def _vendor(fname: str):
|
| 516 |
+
fp = (WEB_DIR / "vendor" / fname).resolve()
|
| 517 |
+
if str(fp).startswith(str((WEB_DIR / "vendor").resolve())) and fp.exists() and fp.is_file():
|
| 518 |
+
mt = "application/javascript" if fname.endswith(".js") else "application/octet-stream"
|
| 519 |
+
return FileResponse(str(fp), media_type=mt)
|
| 520 |
+
return HTMLResponse("not found", status_code=404)
|
| 521 |
+
|
| 522 |
+
started = start_demo_traffic()
|
| 523 |
+
|
| 524 |
+
return {"registered": True, "organ": organ, "base": p,
|
| 525 |
+
"routes": [f"{p}/geo-verdicts", f"{p}/heatmap-3d", f"{p}/gate-status",
|
| 526 |
+
f"{p}/threat-feed (SSE)", "/threat-globe", "/verdict-river",
|
| 527 |
+
"/vendor/{fname}"],
|
| 528 |
+
"demo_traffic": started,
|
| 529 |
+
"inspect_linked": _inspect is not None,
|
| 530 |
+
"doctrine": DOCTRINE["version"],
|
| 531 |
+
"lambda_status": DOCTRINE["lambda_status"]}
|
serve.py
CHANGED
|
@@ -1680,20 +1680,30 @@ except Exception as _see:
|
|
| 1680 |
print(f"[sentra_v4_exec] NOT mounted ({_see!r}); existing routes unaffected", file=_syve.stderr)
|
| 1681 |
|
| 1682 |
|
| 1683 |
-
#
|
| 1684 |
-
#
|
| 1685 |
-
#
|
| 1686 |
-
#
|
| 1687 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1688 |
try:
|
| 1689 |
-
import
|
| 1690 |
-
|
| 1691 |
-
import sys as
|
| 1692 |
-
print(f"[
|
| 1693 |
-
|
| 1694 |
-
|
| 1695 |
-
|
| 1696 |
-
|
|
|
|
|
|
|
| 1697 |
|
| 1698 |
|
| 1699 |
@app.get("/{path:path}")
|
|
|
|
| 1680 |
print(f"[sentra_v4_exec] NOT mounted ({_see!r}); existing routes unaffected", file=_syve.stderr)
|
| 1681 |
|
| 1682 |
|
| 1683 |
+
# ===========================================================================
|
| 1684 |
+
# Sentra PALANTIR-CLASS threat surface (ADDITIVE, 2026-06-02, Yachay /
|
| 1685 |
+
# Co-Authored-By: Perplexity Computer Agent). sentra_v4_threat.register(app,
|
| 1686 |
+
# "sentra") mounts the 3D Threat Globe (/threat-globe), the Verdict River
|
| 1687 |
+
# (/verdict-river), a live SSE threat-feed and the geo/heatmap-3d/gate-status
|
| 1688 |
+
# JSON read views. Three.js is vendored LOCALLY under web/vendor/ and served via
|
| 1689 |
+
# /vendor/{fname} — SOVEREIGN, no CDN. Registered BEFORE the SPA catch-all so the
|
| 1690 |
+
# new routes resolve locally instead of falling through to the landing shell.
|
| 1691 |
+
# try/except-guarded: a missing optional dep can NEVER take down an existing
|
| 1692 |
+
# route. ADDITIVE ONLY. Doctrine v11 LOCKED 749/14/163 · Λ = Conjecture 1.
|
| 1693 |
+
# Patterns stolen (cited in module): Palantir Gotham, CrowdStrike Falcon,
|
| 1694 |
+
# Datadog Watchdog, Splunk.
|
| 1695 |
+
# ---------------------------------------------------------------------------
|
| 1696 |
try:
|
| 1697 |
+
import sentra_v4_threat as _sv4t
|
| 1698 |
+
_sv4t_info = _sv4t.register(app, "sentra")
|
| 1699 |
+
import sys as _syvt
|
| 1700 |
+
print(f"[sentra_v4_threat] mounted: base={_sv4t_info.get('base')}, "
|
| 1701 |
+
f"demo_traffic={_sv4t_info.get('demo_traffic')}, "
|
| 1702 |
+
f"inspect_linked={_sv4t_info.get('inspect_linked')} "
|
| 1703 |
+
f"— /threat-globe + /verdict-river live", file=_syvt)
|
| 1704 |
+
except Exception as _te:
|
| 1705 |
+
import sys as _syvt
|
| 1706 |
+
print(f"[sentra_v4_threat] NOT mounted ({_te!r}); existing routes unaffected", file=_syvt)
|
| 1707 |
|
| 1708 |
|
| 1709 |
@app.get("/{path:path}")
|
web/threat-globe.html
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Sentra · 3D Threat Globe</title>
|
| 7 |
+
<!--
|
| 8 |
+
Sentra PALANTIR-CLASS 3D Threat Globe.
|
| 9 |
+
Author: Yachay <yachay@szlholdings.dev>
|
| 10 |
+
Co-Authored-By: Perplexity Computer Agent <agent@perplexity.ai>
|
| 11 |
+
Doctrine v11 LOCKED 749/14/163 · Λ = Conjecture 1 (NOT a theorem).
|
| 12 |
+
|
| 13 |
+
Patterns stolen (cited):
|
| 14 |
+
* Palantir Gotham -> 3D geospatial canvas + live verdict overlay; click -> dossier.
|
| 15 |
+
* CrowdStrike Falcon -> rotating threat-origin globe.
|
| 16 |
+
* Datadog Watchdog -> heat-map terrain anomaly highlight (mean+2.5σ).
|
| 17 |
+
* Splunk -> high-cardinality field bag on each inspect card.
|
| 18 |
+
Sovereign: Three.js vendored locally (/vendor). Earth texture generated in-browser. No CDN.
|
| 19 |
+
-->
|
| 20 |
+
<script src="/vendor/three.min.js"></script>
|
| 21 |
+
<script src="/vendor/OrbitControls.js"></script>
|
| 22 |
+
<style>
|
| 23 |
+
:root{ --bg:#05080f; --panel:rgba(10,16,26,0.82); --line:#1d2a3e; --txt:#cfe3ff;
|
| 24 |
+
--green:#22e07a; --yellow:#ffd54f; --red:#ff3b3b; --mut:#7790ad; }
|
| 25 |
+
*{box-sizing:border-box}
|
| 26 |
+
html,body{margin:0;height:100%;background:var(--bg);color:var(--txt);
|
| 27 |
+
font-family:"SF Mono",ui-monospace,Menlo,Consolas,monospace;overflow:hidden}
|
| 28 |
+
#globe{position:fixed;inset:0}
|
| 29 |
+
.hdr{position:fixed;top:0;left:0;right:0;padding:10px 16px;z-index:5;
|
| 30 |
+
display:flex;align-items:center;gap:14px;pointer-events:none;
|
| 31 |
+
background:linear-gradient(#05080fcc,#05080f00)}
|
| 32 |
+
.hdr h1{font-size:15px;margin:0;letter-spacing:2px;color:#eaf3ff;font-weight:600}
|
| 33 |
+
.hdr .doc{font-size:10px;color:var(--mut);letter-spacing:1px}
|
| 34 |
+
.tag{font-size:9px;padding:2px 7px;border:1px solid var(--line);border-radius:10px;color:var(--mut)}
|
| 35 |
+
.legend{position:fixed;bottom:12px;left:16px;z-index:5;font-size:11px;
|
| 36 |
+
background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 12px}
|
| 37 |
+
.legend .row{display:flex;align-items:center;gap:7px;margin:3px 0}
|
| 38 |
+
.dot{width:9px;height:9px;border-radius:50%}
|
| 39 |
+
.stat{position:fixed;top:42px;left:16px;z-index:5;font-size:11px;color:var(--mut)}
|
| 40 |
+
.stat b{color:#eaf3ff}
|
| 41 |
+
/* gate indicator rail */
|
| 42 |
+
#gates{position:fixed;top:0;right:0;height:100%;width:210px;z-index:6;
|
| 43 |
+
display:flex;flex-direction:column;gap:6px;padding:54px 10px 10px;overflow:hidden;
|
| 44 |
+
background:linear-gradient(270deg,#05080fee,#05080f55,#05080f00)}
|
| 45 |
+
.gate{border:1px solid var(--line);border-radius:7px;padding:6px 8px;
|
| 46 |
+
background:var(--panel);transition:box-shadow .25s,border-color .25s;position:relative}
|
| 47 |
+
.gate .gname{font-size:10px;color:#dceaff;letter-spacing:.4px}
|
| 48 |
+
.gate .gsub{font-size:9px;color:var(--mut);margin-top:2px}
|
| 49 |
+
.gate .gled{position:absolute;top:7px;right:7px;width:8px;height:8px;border-radius:50%;
|
| 50 |
+
background:#26344a;box-shadow:0 0 0 0 transparent}
|
| 51 |
+
.gate.firing{border-color:#ff5252}
|
| 52 |
+
.gate.firing .gled{background:#ff5252;box-shadow:0 0 12px 3px #ff5252cc;animation:pulse .9s infinite}
|
| 53 |
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
|
| 54 |
+
/* heatmap terrain panel */
|
| 55 |
+
#heatwrap{position:fixed;left:16px;bottom:64px;z-index:6;width:330px;height:230px;
|
| 56 |
+
background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
| 57 |
+
#heatwrap .ht{position:absolute;top:6px;left:10px;font-size:10px;color:var(--mut);
|
| 58 |
+
z-index:2;letter-spacing:.5px}
|
| 59 |
+
#heat{width:100%;height:100%}
|
| 60 |
+
/* inspect dossier card */
|
| 61 |
+
#card{position:fixed;top:54px;right:230px;width:380px;max-height:80vh;overflow:auto;z-index:9;
|
| 62 |
+
background:var(--panel);border:1px solid #2a3b55;border-radius:12px;padding:14px 16px;
|
| 63 |
+
display:none;backdrop-filter:blur(8px);box-shadow:0 18px 60px #000a}
|
| 64 |
+
#card h2{font-size:13px;margin:0 0 8px;letter-spacing:1px}
|
| 65 |
+
#card .kv{display:flex;justify-content:space-between;gap:10px;font-size:11px;padding:3px 0;
|
| 66 |
+
border-bottom:1px solid #15202f}
|
| 67 |
+
#card .kv span:first-child{color:var(--mut)}
|
| 68 |
+
#card .kv span:last-child{color:#eaf3ff;text-align:right;word-break:break-all}
|
| 69 |
+
#card .vbadge{font-weight:700;letter-spacing:1px}
|
| 70 |
+
#card .close{position:absolute;top:10px;right:12px;cursor:pointer;color:var(--mut);font-size:14px}
|
| 71 |
+
#card .receipt{margin-top:8px;font-size:10px;color:#9fd;background:#0c1622;border:1px solid #14283a;
|
| 72 |
+
border-radius:6px;padding:7px;word-break:break-all}
|
| 73 |
+
.conn{position:fixed;bottom:12px;right:16px;z-index:6;font-size:10px;color:var(--mut)}
|
| 74 |
+
.conn b{color:var(--green)}
|
| 75 |
+
a.nav{pointer-events:auto;color:#5fb0ff;font-size:11px;text-decoration:none;border:1px solid var(--line);
|
| 76 |
+
padding:3px 9px;border-radius:8px}
|
| 77 |
+
</style>
|
| 78 |
+
</head>
|
| 79 |
+
<body>
|
| 80 |
+
<div id="globe"></div>
|
| 81 |
+
<div class="hdr">
|
| 82 |
+
<h1>SENTRA · THREAT GLOBE</h1>
|
| 83 |
+
<span class="tag">IMMUNE SENTINEL</span>
|
| 84 |
+
<span class="doc">Doctrine v11 · 749/14/163 LOCKED · Λ Conjecture 1</span>
|
| 85 |
+
<span style="flex:1"></span>
|
| 86 |
+
<a class="nav" href="/verdict-river">→ Verdict River</a>
|
| 87 |
+
</div>
|
| 88 |
+
<div class="stat" id="stat">live verdicts <b id="vc">0</b> · ALLOW <b id="ca" style="color:var(--green)">0</b>
|
| 89 |
+
· REVIEW <b id="cr" style="color:var(--yellow)">0</b> · DENY <b id="cd" style="color:var(--red)">0</b></div>
|
| 90 |
+
|
| 91 |
+
<div id="gates"></div>
|
| 92 |
+
|
| 93 |
+
<div id="heatwrap">
|
| 94 |
+
<div class="ht">GATE-FIRE HEATMAP · 3D TERRAIN · 8 gates × 30s buckets · h=rejects · Datadog-Watchdog anomaly</div>
|
| 95 |
+
<div id="heat"></div>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<div id="card">
|
| 99 |
+
<span class="close" onclick="document.getElementById('card').style.display='none'">✕</span>
|
| 100 |
+
<h2>VERDICT INSPECT · <span id="cardv" class="vbadge"></span></h2>
|
| 101 |
+
<div id="cardbody"></div>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<div class="legend">
|
| 105 |
+
<div class="row"><span class="dot" style="background:var(--green)"></span> ALLOW (PASS)</div>
|
| 106 |
+
<div class="row"><span class="dot" style="background:var(--yellow)"></span> REVIEW (borderline λ)</div>
|
| 107 |
+
<div class="row"><span class="dot" style="background:var(--red)"></span> DENY (fail-CLOSED REJECT)</div>
|
| 108 |
+
</div>
|
| 109 |
+
<div class="conn">SSE <b id="connstate">connecting…</b></div>
|
| 110 |
+
|
| 111 |
+
<script>
|
| 112 |
+
// ============================ GLOBE SCENE ============================
|
| 113 |
+
const GW = document.getElementById('globe');
|
| 114 |
+
const scene = new THREE.Scene();
|
| 115 |
+
scene.fog = new THREE.FogExp2(0x05080f, 0.018);
|
| 116 |
+
const camera = new THREE.PerspectiveCamera(45, window.innerWidth/window.innerHeight, 0.1, 1000);
|
| 117 |
+
camera.position.set(0, 1.6, 6.2);
|
| 118 |
+
const renderer = new THREE.WebGLRenderer({antialias:true, alpha:true});
|
| 119 |
+
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
|
| 120 |
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
| 121 |
+
GW.appendChild(renderer.domElement);
|
| 122 |
+
|
| 123 |
+
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
| 124 |
+
controls.enableDamping = true; controls.dampingFactor = 0.06;
|
| 125 |
+
controls.minDistance = 3.2; controls.maxDistance = 14; controls.autoRotate = true;
|
| 126 |
+
controls.autoRotateSpeed = 0.35;
|
| 127 |
+
|
| 128 |
+
scene.add(new THREE.AmbientLight(0x4a6080, 0.9));
|
| 129 |
+
const dir = new THREE.DirectionalLight(0x9fc4ff, 1.0); dir.position.set(5,3,5); scene.add(dir);
|
| 130 |
+
|
| 131 |
+
// starfield
|
| 132 |
+
(function(){
|
| 133 |
+
const g = new THREE.BufferGeometry(); const n=1800; const pos=new Float32Array(n*3);
|
| 134 |
+
for(let i=0;i<n;i++){ const r=40+Math.random()*60, t=Math.random()*Math.PI*2, p=Math.acos(2*Math.random()-1);
|
| 135 |
+
pos[i*3]=r*Math.sin(p)*Math.cos(t); pos[i*3+1]=r*Math.cos(p); pos[i*3+2]=r*Math.sin(p)*Math.sin(t); }
|
| 136 |
+
g.setAttribute('position', new THREE.BufferAttribute(pos,3));
|
| 137 |
+
scene.add(new THREE.Points(g, new THREE.PointsMaterial({color:0x33507a,size:0.18,transparent:true,opacity:0.7})));
|
| 138 |
+
})();
|
| 139 |
+
|
| 140 |
+
const R = 2.0;
|
| 141 |
+
// ---- procedural Web-Mercator earth texture (sovereign, drawn in-browser) ----
|
| 142 |
+
function makeEarthTexture(){
|
| 143 |
+
const c = document.createElement('canvas'); c.width=2048; c.height=1024;
|
| 144 |
+
const x = c.getContext('2d');
|
| 145 |
+
const grd = x.createLinearGradient(0,0,0,1024);
|
| 146 |
+
grd.addColorStop(0,'#0a1a2e'); grd.addColorStop(0.5,'#08243d'); grd.addColorStop(1,'#0a1a2e');
|
| 147 |
+
x.fillStyle=grd; x.fillRect(0,0,2048,1024);
|
| 148 |
+
// graticule
|
| 149 |
+
x.strokeStyle='rgba(60,110,160,0.20)'; x.lineWidth=1;
|
| 150 |
+
for(let lon=0;lon<=360;lon+=15){const px=lon/360*2048;x.beginPath();x.moveTo(px,0);x.lineTo(px,1024);x.stroke();}
|
| 151 |
+
for(let lat=0;lat<=180;lat+=15){const py=lat/180*1024;x.beginPath();x.moveTo(0,py);x.lineTo(2048,py);x.stroke();}
|
| 152 |
+
// simplified continents as filled blobs in equirectangular (lon[-180,180],lat[-90,90])
|
| 153 |
+
function proj(lon,lat){return [ (lon+180)/360*2048, (90-lat)/180*1024 ];}
|
| 154 |
+
function blob(pts,fill){ x.fillStyle=fill; x.beginPath();
|
| 155 |
+
pts.forEach((p,i)=>{const q=proj(p[0],p[1]); i?x.lineTo(q[0],q[1]):x.moveTo(q[0],q[1]);}); x.closePath(); x.fill(); }
|
| 156 |
+
const land='rgba(40,90,70,0.85)';
|
| 157 |
+
blob([[-168,68],[-150,60],[-120,49],[-100,30],[-97,18],[-83,9],[-78,8],[-82,25],[-80,40],[-67,45],[-60,50],[-95,60],[-130,70],[-165,71]],land); // N. America
|
| 158 |
+
blob([[-80,8],[-70,-5],[-72,-20],[-66,-40],[-72,-52],[-58,-34],[-48,-25],[-35,-8],[-50,0],[-62,8]],land); // S. America
|
| 159 |
+
blob([[-12,35],[0,45],[10,55],[25,60],[40,67],[60,68],[100,72],[140,70],[150,60],[120,45],[100,30],[78,8],[60,25],[44,12],[32,30],[10,34],[-6,36]],land); // Eurasia
|
| 160 |
+
blob([[-16,15],[10,5],[20,-5],[28,-20],[20,-34],[15,-30],[10,4],[-8,5],[-16,15]],land); // Africa
|
| 161 |
+
blob([[114,-20],[130,-12],[145,-18],[153,-28],[140,-38],[120,-34],[114,-22]],land); // Australia
|
| 162 |
+
// city glow points
|
| 163 |
+
x.fillStyle='rgba(120,200,255,0.5)';
|
| 164 |
+
[[-77,39],[4.9,52],[103,1.3],[8.7,50],[-0.1,51],[-46,-23],[139,35],[151,-33],[34,32],[37,55],[-122,37]]
|
| 165 |
+
.forEach(p=>{const q=proj(p[0],p[1]);x.beginPath();x.arc(q[0],q[1],3,0,7);x.fill();});
|
| 166 |
+
const tex = new THREE.CanvasTexture(c); tex.anisotropy=4; return tex;
|
| 167 |
+
}
|
| 168 |
+
const earth = new THREE.Mesh(
|
| 169 |
+
new THREE.SphereGeometry(R, 96, 96),
|
| 170 |
+
new THREE.MeshPhongMaterial({map:makeEarthTexture(), shininess:6, specular:0x113355})
|
| 171 |
+
);
|
| 172 |
+
scene.add(earth);
|
| 173 |
+
// atmosphere halo
|
| 174 |
+
scene.add(new THREE.Mesh(new THREE.SphereGeometry(R*1.025,64,64),
|
| 175 |
+
new THREE.MeshBasicMaterial({color:0x2e6cff,transparent:true,opacity:0.07,side:THREE.BackSide})));
|
| 176 |
+
|
| 177 |
+
function llToVec(lat,lon,rad){
|
| 178 |
+
const phi=(90-lat)*Math.PI/180, th=(lon+180)*Math.PI/180;
|
| 179 |
+
return new THREE.Vector3(-rad*Math.sin(phi)*Math.cos(th), rad*Math.cos(phi), rad*Math.sin(phi)*Math.sin(th));
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// verdict pins
|
| 183 |
+
const pinGroup = new THREE.Group(); earth.add(pinGroup);
|
| 184 |
+
const pins = new Map(); // id -> {mesh, born, status}
|
| 185 |
+
const COL = {ALLOW:0x22e07a, REVIEW:0xffd54f, DENY:0xff3b3b};
|
| 186 |
+
function addPin(v){
|
| 187 |
+
if(pins.has(v.id)) return;
|
| 188 |
+
const pos = llToVec(v.lat, v.lon, R*1.01);
|
| 189 |
+
const col = COL[v.status]||0x22e07a;
|
| 190 |
+
const m = new THREE.Mesh(new THREE.SphereGeometry(0.022,10,10),
|
| 191 |
+
new THREE.MeshBasicMaterial({color:col}));
|
| 192 |
+
m.position.copy(pos); m.userData = v;
|
| 193 |
+
// beam
|
| 194 |
+
const beam = new THREE.Mesh(new THREE.CylinderGeometry(0.004,0.004, v.status==='DENY'?0.42:0.22, 6),
|
| 195 |
+
new THREE.MeshBasicMaterial({color:col, transparent:true, opacity:0.55}));
|
| 196 |
+
beam.position.copy(pos.clone().multiplyScalar(1.0 + (v.status==='DENY'?0.10:0.055)));
|
| 197 |
+
beam.lookAt(0,0,0); beam.rotateX(Math.PI/2);
|
| 198 |
+
// pulse ring
|
| 199 |
+
const ring = new THREE.Mesh(new THREE.RingGeometry(0.02,0.05,18),
|
| 200 |
+
new THREE.MeshBasicMaterial({color:col,transparent:true,opacity:0.9,side:THREE.DoubleSide}));
|
| 201 |
+
ring.position.copy(pos); ring.lookAt(pos.clone().multiplyScalar(2));
|
| 202 |
+
pinGroup.add(m); pinGroup.add(beam); pinGroup.add(ring);
|
| 203 |
+
pins.set(v.id, {mesh:m, beam, ring, born:performance.now(), status:v.status, data:v});
|
| 204 |
+
// cap
|
| 205 |
+
if(pins.size>600){ const k=pins.keys().next().value; const o=pins.get(k);
|
| 206 |
+
pinGroup.remove(o.mesh,o.beam,o.ring); pins.delete(k); }
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// gate fire arcs glowing on globe edge handled in rail; here flash earth on DENY
|
| 210 |
+
let denyFlash=0;
|
| 211 |
+
function flashDeny(){ denyFlash=1; }
|
| 212 |
+
|
| 213 |
+
// raycast click -> inspect card
|
| 214 |
+
const ray = new THREE.Raycaster(); const mouse = new THREE.Vector2();
|
| 215 |
+
renderer.domElement.addEventListener('click', (e)=>{
|
| 216 |
+
mouse.x=(e.clientX/window.innerWidth)*2-1; mouse.y=-(e.clientY/window.innerHeight)*2+1;
|
| 217 |
+
ray.setFromCamera(mouse, camera);
|
| 218 |
+
const hits = ray.intersectObjects([...pins.values()].map(p=>p.mesh));
|
| 219 |
+
if(hits.length){ openCard(hits[0].object.userData); }
|
| 220 |
+
});
|
| 221 |
+
|
| 222 |
+
// counts
|
| 223 |
+
let cA=0,cR=0,cD=0,cTot=0;
|
| 224 |
+
function bump(s){ cTot++; if(s==='ALLOW')cA++; else if(s==='REVIEW')cR++; else cD++;
|
| 225 |
+
vc.textContent=cTot; ca.textContent=cA; cr.textContent=cR; cd.textContent=cD; }
|
| 226 |
+
|
| 227 |
+
// ============================ INSPECT CARD ============================
|
| 228 |
+
function openCard(v){
|
| 229 |
+
controls.autoRotate=false;
|
| 230 |
+
const card=document.getElementById('card');
|
| 231 |
+
document.getElementById('cardv').textContent=v.status;
|
| 232 |
+
document.getElementById('cardv').style.color = v.status==='ALLOW'?'#22e07a':v.status==='REVIEW'?'#ffd54f':'#ff3b3b';
|
| 233 |
+
const rows=[
|
| 234 |
+
['verdict (raw)', v.verdict],
|
| 235 |
+
['status', v.status],
|
| 236 |
+
['origin', v.region],
|
| 237 |
+
['lat / lon', v.lat+' , '+v.lon],
|
| 238 |
+
['source ip', v.ip||'—'],
|
| 239 |
+
['category', v.category],
|
| 240 |
+
['gates passed', (v.gates_passed??'—')+' / '+(v.gates_total??8)],
|
| 241 |
+
['rejected gates', (v.rejected_gates&&v.rejected_gates.length)?v.rejected_gates.join(', '):'none'],
|
| 242 |
+
['λ geo-mean', v.lambda_geo??'—'],
|
| 243 |
+
['actor', v.actor||'—'],
|
| 244 |
+
['source', v.source||'live'],
|
| 245 |
+
['signed', v.signed? 'true':'false'],
|
| 246 |
+
['timestamp', v.ts],
|
| 247 |
+
['action', (v.action_preview||'').slice(0,120)],
|
| 248 |
+
];
|
| 249 |
+
document.getElementById('cardbody').innerHTML =
|
| 250 |
+
rows.map(r=>`<div class="kv"><span>${r[0]}</span><span>${r[1]}</span></div>`).join('')
|
| 251 |
+
+ `<div class="receipt"><b>Khipu receipt (DSSE)</b><br>${v.receipt_sha||'(unsigned — no cosign secret)'}</div>`;
|
| 252 |
+
card.style.display='block';
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
// ============================ GATE RAIL ============================
|
| 256 |
+
const GATE_EL = new Map();
|
| 257 |
+
fetch('/api/sentra/v4/gate-status').then(r=>r.json()).then(d=>{
|
| 258 |
+
const wrap=document.getElementById('gates');
|
| 259 |
+
d.gates.forEach(g=>{
|
| 260 |
+
const el=document.createElement('div'); el.className='gate';
|
| 261 |
+
el.innerHTML=`<div class="gled"></div><div class="gname">${g.label}</div>
|
| 262 |
+
<div class="gsub">${g.name} · ${g.category}</div>`;
|
| 263 |
+
el.onclick=()=>{}; wrap.appendChild(el); GATE_EL.set(g.id, el);
|
| 264 |
+
});
|
| 265 |
+
}).catch(()=>{});
|
| 266 |
+
function applyGates(gs){
|
| 267 |
+
gs.gates.forEach(g=>{ const el=GATE_EL.get(g.id); if(!el)return;
|
| 268 |
+
el.classList.toggle('firing', !!g.firing); });
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
// ============================ HEATMAP TERRAIN ============================
|
| 272 |
+
const HW=330,HH=230;
|
| 273 |
+
const hScene=new THREE.Scene();
|
| 274 |
+
const hCam=new THREE.PerspectiveCamera(42, HW/HH, 0.1, 100); hCam.position.set(0,7.5,9.5); hCam.lookAt(0,0,0);
|
| 275 |
+
const hRend=new THREE.WebGLRenderer({antialias:true,alpha:true}); hRend.setSize(HW,HH);
|
| 276 |
+
hRend.setPixelRatio(Math.min(2,window.devicePixelRatio));
|
| 277 |
+
document.getElementById('heat').appendChild(hRend.domElement);
|
| 278 |
+
hScene.add(new THREE.AmbientLight(0xffffff,0.85));
|
| 279 |
+
const hDir=new THREE.DirectionalLight(0xffffff,0.7); hDir.position.set(4,8,5); hScene.add(hDir);
|
| 280 |
+
const hCtrl=new THREE.OrbitControls(hCam, hRend.domElement);
|
| 281 |
+
hCtrl.enableDamping=true; hCtrl.enablePan=false; hCtrl.minDistance=6; hCtrl.maxDistance=20;
|
| 282 |
+
hCtrl.autoRotate=true; hCtrl.autoRotateSpeed=0.8;
|
| 283 |
+
const hBars=new THREE.Group(); hScene.add(hBars);
|
| 284 |
+
const GATES_N=8, BUCKETS_SHOW=30; // show last 30 buckets (15 min) for legibility
|
| 285 |
+
function hexToColor(h){return new THREE.Color(h);}
|
| 286 |
+
function buildTerrain(d){
|
| 287 |
+
while(hBars.children.length) hBars.remove(hBars.children[0]);
|
| 288 |
+
const gates=d.gates.slice(0,GATES_N);
|
| 289 |
+
const maxc=Math.max(1,d.max_cell);
|
| 290 |
+
const dx=0.62, dz=0.5;
|
| 291 |
+
const gx=(gates.length-1)*dx;
|
| 292 |
+
gates.forEach((g,gi)=>{
|
| 293 |
+
const cells=g.cells.slice(-BUCKETS_SHOW);
|
| 294 |
+
const cz=(cells.length-1)*dz;
|
| 295 |
+
const sev=hexToColor(g.severity_color);
|
| 296 |
+
cells.forEach((c,bi)=>{
|
| 297 |
+
const h=0.05+(c/maxc)*3.2;
|
| 298 |
+
const geo=new THREE.BoxGeometry(0.5,h,0.42);
|
| 299 |
+
const anom = g.anomaly_buckets.includes(g.cells.length-BUCKETS_SHOW+bi);
|
| 300 |
+
const col = sev.clone(); if(c===0) col.multiplyScalar(0.25);
|
| 301 |
+
const mat=new THREE.MeshLambertMaterial({color:col,
|
| 302 |
+
emissive: anom? new THREE.Color(0xffffff): col.clone().multiplyScalar(0.15),
|
| 303 |
+
emissiveIntensity: anom?0.9:0.3});
|
| 304 |
+
const m=new THREE.Mesh(geo,mat);
|
| 305 |
+
m.position.set(gi*dx-gx/2, h/2, bi*dz-cz/2);
|
| 306 |
+
hBars.add(m);
|
| 307 |
+
});
|
| 308 |
+
});
|
| 309 |
+
}
|
| 310 |
+
function refreshHeatmap(){ fetch('/api/sentra/v4/heatmap-3d').then(r=>r.json()).then(buildTerrain).catch(()=>{}); }
|
| 311 |
+
refreshHeatmap(); setInterval(refreshHeatmap, 4000);
|
| 312 |
+
|
| 313 |
+
// ============================ SSE STREAM ============================
|
| 314 |
+
function connect(){
|
| 315 |
+
const es=new EventSource('/api/sentra/v4/threat-feed');
|
| 316 |
+
es.addEventListener('snapshot', e=>{
|
| 317 |
+
const d=JSON.parse(e.data);
|
| 318 |
+
document.getElementById('connstate').textContent='live';
|
| 319 |
+
d.verdicts.slice().reverse().forEach(v=>{ addPin(v); bump(v.status); });
|
| 320 |
+
});
|
| 321 |
+
es.addEventListener('verdict', e=>{
|
| 322 |
+
const v=JSON.parse(e.data); addPin(v); bump(v.status);
|
| 323 |
+
if(v.status==='DENY') flashDeny();
|
| 324 |
+
});
|
| 325 |
+
es.addEventListener('gates', e=>{ applyGates(JSON.parse(e.data)); });
|
| 326 |
+
es.onopen=()=>document.getElementById('connstate').textContent='live';
|
| 327 |
+
es.onerror=()=>{ document.getElementById('connstate').textContent='reconnecting…';
|
| 328 |
+
es.close(); setTimeout(connect, 2500); };
|
| 329 |
+
}
|
| 330 |
+
connect();
|
| 331 |
+
|
| 332 |
+
// ============================ ANIMATE ============================
|
| 333 |
+
function animate(t){
|
| 334 |
+
requestAnimationFrame(animate);
|
| 335 |
+
controls.update();
|
| 336 |
+
// pulse rings expand+fade
|
| 337 |
+
const now=performance.now();
|
| 338 |
+
pins.forEach((p,id)=>{
|
| 339 |
+
const age=(now-p.born)/1000;
|
| 340 |
+
if(p.ring){ const s=1+Math.min(age,1.2)*2.2; p.ring.scale.setScalar(s);
|
| 341 |
+
p.ring.material.opacity=Math.max(0, 0.9-age*0.8); }
|
| 342 |
+
const pulse=1+0.4*Math.sin(now*0.006 + (id.charCodeAt(0)||0));
|
| 343 |
+
p.mesh.scale.setScalar(p.status==='DENY'?pulse*1.3:pulse);
|
| 344 |
+
});
|
| 345 |
+
if(denyFlash>0){ denyFlash=Math.max(0,denyFlash-0.04);
|
| 346 |
+
earth.material.emissive=new THREE.Color(0xff0000); earth.material.emissiveIntensity=denyFlash*0.5; }
|
| 347 |
+
else { earth.material.emissiveIntensity=0; }
|
| 348 |
+
renderer.render(scene,camera);
|
| 349 |
+
hCtrl.update(); hRend.render(hScene,hCam);
|
| 350 |
+
}
|
| 351 |
+
animate();
|
| 352 |
+
window.addEventListener('resize', ()=>{
|
| 353 |
+
camera.aspect=window.innerWidth/window.innerHeight; camera.updateProjectionMatrix();
|
| 354 |
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
| 355 |
+
});
|
| 356 |
+
</script>
|
| 357 |
+
</body>
|
| 358 |
+
</html>
|
web/vendor/OrbitControls.js
ADDED
|
@@ -0,0 +1,1045 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
( function () {
|
| 2 |
+
|
| 3 |
+
// Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default).
|
| 4 |
+
//
|
| 5 |
+
// Orbit - left mouse / touch: one-finger move
|
| 6 |
+
// Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish
|
| 7 |
+
// Pan - right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move
|
| 8 |
+
|
| 9 |
+
const _changeEvent = {
|
| 10 |
+
type: 'change'
|
| 11 |
+
};
|
| 12 |
+
const _startEvent = {
|
| 13 |
+
type: 'start'
|
| 14 |
+
};
|
| 15 |
+
const _endEvent = {
|
| 16 |
+
type: 'end'
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
class OrbitControls extends THREE.EventDispatcher {
|
| 20 |
+
|
| 21 |
+
constructor( object, domElement ) {
|
| 22 |
+
|
| 23 |
+
super();
|
| 24 |
+
if ( domElement === undefined ) console.warn( 'THREE.OrbitControls: The second parameter "domElement" is now mandatory.' );
|
| 25 |
+
if ( domElement === document ) console.error( 'THREE.OrbitControls: "document" should not be used as the target "domElement". Please use "renderer.domElement" instead.' );
|
| 26 |
+
this.object = object;
|
| 27 |
+
this.domElement = domElement; // Set to false to disable this control
|
| 28 |
+
|
| 29 |
+
this.enabled = true; // "target" sets the location of focus, where the object orbits around
|
| 30 |
+
|
| 31 |
+
this.target = new THREE.Vector3(); // How far you can dolly in and out ( PerspectiveCamera only )
|
| 32 |
+
|
| 33 |
+
this.minDistance = 0;
|
| 34 |
+
this.maxDistance = Infinity; // How far you can zoom in and out ( OrthographicCamera only )
|
| 35 |
+
|
| 36 |
+
this.minZoom = 0;
|
| 37 |
+
this.maxZoom = Infinity; // How far you can orbit vertically, upper and lower limits.
|
| 38 |
+
// Range is 0 to Math.PI radians.
|
| 39 |
+
|
| 40 |
+
this.minPolarAngle = 0; // radians
|
| 41 |
+
|
| 42 |
+
this.maxPolarAngle = Math.PI; // radians
|
| 43 |
+
// How far you can orbit horizontally, upper and lower limits.
|
| 44 |
+
// If set, the interval [ min, max ] must be a sub-interval of [ - 2 PI, 2 PI ], with ( max - min < 2 PI )
|
| 45 |
+
|
| 46 |
+
this.minAzimuthAngle = - Infinity; // radians
|
| 47 |
+
|
| 48 |
+
this.maxAzimuthAngle = Infinity; // radians
|
| 49 |
+
// Set to true to enable damping (inertia)
|
| 50 |
+
// If damping is enabled, you must call controls.update() in your animation loop
|
| 51 |
+
|
| 52 |
+
this.enableDamping = false;
|
| 53 |
+
this.dampingFactor = 0.05; // This option actually enables dollying in and out; left as "zoom" for backwards compatibility.
|
| 54 |
+
// Set to false to disable zooming
|
| 55 |
+
|
| 56 |
+
this.enableZoom = true;
|
| 57 |
+
this.zoomSpeed = 1.0; // Set to false to disable rotating
|
| 58 |
+
|
| 59 |
+
this.enableRotate = true;
|
| 60 |
+
this.rotateSpeed = 1.0; // Set to false to disable panning
|
| 61 |
+
|
| 62 |
+
this.enablePan = true;
|
| 63 |
+
this.panSpeed = 1.0;
|
| 64 |
+
this.screenSpacePanning = true; // if false, pan orthogonal to world-space direction camera.up
|
| 65 |
+
|
| 66 |
+
this.keyPanSpeed = 7.0; // pixels moved per arrow key push
|
| 67 |
+
// Set to true to automatically rotate around the target
|
| 68 |
+
// If auto-rotate is enabled, you must call controls.update() in your animation loop
|
| 69 |
+
|
| 70 |
+
this.autoRotate = false;
|
| 71 |
+
this.autoRotateSpeed = 2.0; // 30 seconds per orbit when fps is 60
|
| 72 |
+
// The four arrow keys
|
| 73 |
+
|
| 74 |
+
this.keys = {
|
| 75 |
+
LEFT: 'ArrowLeft',
|
| 76 |
+
UP: 'ArrowUp',
|
| 77 |
+
RIGHT: 'ArrowRight',
|
| 78 |
+
BOTTOM: 'ArrowDown'
|
| 79 |
+
}; // Mouse buttons
|
| 80 |
+
|
| 81 |
+
this.mouseButtons = {
|
| 82 |
+
LEFT: THREE.MOUSE.ROTATE,
|
| 83 |
+
MIDDLE: THREE.MOUSE.DOLLY,
|
| 84 |
+
RIGHT: THREE.MOUSE.PAN
|
| 85 |
+
}; // Touch fingers
|
| 86 |
+
|
| 87 |
+
this.touches = {
|
| 88 |
+
ONE: THREE.TOUCH.ROTATE,
|
| 89 |
+
TWO: THREE.TOUCH.DOLLY_PAN
|
| 90 |
+
}; // for reset
|
| 91 |
+
|
| 92 |
+
this.target0 = this.target.clone();
|
| 93 |
+
this.position0 = this.object.position.clone();
|
| 94 |
+
this.zoom0 = this.object.zoom; // the target DOM element for key events
|
| 95 |
+
|
| 96 |
+
this._domElementKeyEvents = null; //
|
| 97 |
+
// public methods
|
| 98 |
+
//
|
| 99 |
+
|
| 100 |
+
this.getPolarAngle = function () {
|
| 101 |
+
|
| 102 |
+
return spherical.phi;
|
| 103 |
+
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
this.getAzimuthalAngle = function () {
|
| 107 |
+
|
| 108 |
+
return spherical.theta;
|
| 109 |
+
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
this.listenToKeyEvents = function ( domElement ) {
|
| 113 |
+
|
| 114 |
+
domElement.addEventListener( 'keydown', onKeyDown );
|
| 115 |
+
this._domElementKeyEvents = domElement;
|
| 116 |
+
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
this.saveState = function () {
|
| 120 |
+
|
| 121 |
+
scope.target0.copy( scope.target );
|
| 122 |
+
scope.position0.copy( scope.object.position );
|
| 123 |
+
scope.zoom0 = scope.object.zoom;
|
| 124 |
+
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
this.reset = function () {
|
| 128 |
+
|
| 129 |
+
scope.target.copy( scope.target0 );
|
| 130 |
+
scope.object.position.copy( scope.position0 );
|
| 131 |
+
scope.object.zoom = scope.zoom0;
|
| 132 |
+
scope.object.updateProjectionMatrix();
|
| 133 |
+
scope.dispatchEvent( _changeEvent );
|
| 134 |
+
scope.update();
|
| 135 |
+
state = STATE.NONE;
|
| 136 |
+
|
| 137 |
+
}; // this method is exposed, but perhaps it would be better if we can make it private...
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
this.update = function () {
|
| 141 |
+
|
| 142 |
+
const offset = new THREE.Vector3(); // so camera.up is the orbit axis
|
| 143 |
+
|
| 144 |
+
const quat = new THREE.Quaternion().setFromUnitVectors( object.up, new THREE.Vector3( 0, 1, 0 ) );
|
| 145 |
+
const quatInverse = quat.clone().invert();
|
| 146 |
+
const lastPosition = new THREE.Vector3();
|
| 147 |
+
const lastQuaternion = new THREE.Quaternion();
|
| 148 |
+
const twoPI = 2 * Math.PI;
|
| 149 |
+
return function update() {
|
| 150 |
+
|
| 151 |
+
const position = scope.object.position;
|
| 152 |
+
offset.copy( position ).sub( scope.target ); // rotate offset to "y-axis-is-up" space
|
| 153 |
+
|
| 154 |
+
offset.applyQuaternion( quat ); // angle from z-axis around y-axis
|
| 155 |
+
|
| 156 |
+
spherical.setFromVector3( offset );
|
| 157 |
+
|
| 158 |
+
if ( scope.autoRotate && state === STATE.NONE ) {
|
| 159 |
+
|
| 160 |
+
rotateLeft( getAutoRotationAngle() );
|
| 161 |
+
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
if ( scope.enableDamping ) {
|
| 165 |
+
|
| 166 |
+
spherical.theta += sphericalDelta.theta * scope.dampingFactor;
|
| 167 |
+
spherical.phi += sphericalDelta.phi * scope.dampingFactor;
|
| 168 |
+
|
| 169 |
+
} else {
|
| 170 |
+
|
| 171 |
+
spherical.theta += sphericalDelta.theta;
|
| 172 |
+
spherical.phi += sphericalDelta.phi;
|
| 173 |
+
|
| 174 |
+
} // restrict theta to be between desired limits
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
let min = scope.minAzimuthAngle;
|
| 178 |
+
let max = scope.maxAzimuthAngle;
|
| 179 |
+
|
| 180 |
+
if ( isFinite( min ) && isFinite( max ) ) {
|
| 181 |
+
|
| 182 |
+
if ( min < - Math.PI ) min += twoPI; else if ( min > Math.PI ) min -= twoPI;
|
| 183 |
+
if ( max < - Math.PI ) max += twoPI; else if ( max > Math.PI ) max -= twoPI;
|
| 184 |
+
|
| 185 |
+
if ( min <= max ) {
|
| 186 |
+
|
| 187 |
+
spherical.theta = Math.max( min, Math.min( max, spherical.theta ) );
|
| 188 |
+
|
| 189 |
+
} else {
|
| 190 |
+
|
| 191 |
+
spherical.theta = spherical.theta > ( min + max ) / 2 ? Math.max( min, spherical.theta ) : Math.min( max, spherical.theta );
|
| 192 |
+
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
} // restrict phi to be between desired limits
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
spherical.phi = Math.max( scope.minPolarAngle, Math.min( scope.maxPolarAngle, spherical.phi ) );
|
| 199 |
+
spherical.makeSafe();
|
| 200 |
+
spherical.radius *= scale; // restrict radius to be between desired limits
|
| 201 |
+
|
| 202 |
+
spherical.radius = Math.max( scope.minDistance, Math.min( scope.maxDistance, spherical.radius ) ); // move target to panned location
|
| 203 |
+
|
| 204 |
+
if ( scope.enableDamping === true ) {
|
| 205 |
+
|
| 206 |
+
scope.target.addScaledVector( panOffset, scope.dampingFactor );
|
| 207 |
+
|
| 208 |
+
} else {
|
| 209 |
+
|
| 210 |
+
scope.target.add( panOffset );
|
| 211 |
+
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
offset.setFromSpherical( spherical ); // rotate offset back to "camera-up-vector-is-up" space
|
| 215 |
+
|
| 216 |
+
offset.applyQuaternion( quatInverse );
|
| 217 |
+
position.copy( scope.target ).add( offset );
|
| 218 |
+
scope.object.lookAt( scope.target );
|
| 219 |
+
|
| 220 |
+
if ( scope.enableDamping === true ) {
|
| 221 |
+
|
| 222 |
+
sphericalDelta.theta *= 1 - scope.dampingFactor;
|
| 223 |
+
sphericalDelta.phi *= 1 - scope.dampingFactor;
|
| 224 |
+
panOffset.multiplyScalar( 1 - scope.dampingFactor );
|
| 225 |
+
|
| 226 |
+
} else {
|
| 227 |
+
|
| 228 |
+
sphericalDelta.set( 0, 0, 0 );
|
| 229 |
+
panOffset.set( 0, 0, 0 );
|
| 230 |
+
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
scale = 1; // update condition is:
|
| 234 |
+
// min(camera displacement, camera rotation in radians)^2 > EPS
|
| 235 |
+
// using small-angle approximation cos(x/2) = 1 - x^2 / 8
|
| 236 |
+
|
| 237 |
+
if ( zoomChanged || lastPosition.distanceToSquared( scope.object.position ) > EPS || 8 * ( 1 - lastQuaternion.dot( scope.object.quaternion ) ) > EPS ) {
|
| 238 |
+
|
| 239 |
+
scope.dispatchEvent( _changeEvent );
|
| 240 |
+
lastPosition.copy( scope.object.position );
|
| 241 |
+
lastQuaternion.copy( scope.object.quaternion );
|
| 242 |
+
zoomChanged = false;
|
| 243 |
+
return true;
|
| 244 |
+
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
return false;
|
| 248 |
+
|
| 249 |
+
};
|
| 250 |
+
|
| 251 |
+
}();
|
| 252 |
+
|
| 253 |
+
this.dispose = function () {
|
| 254 |
+
|
| 255 |
+
scope.domElement.removeEventListener( 'contextmenu', onContextMenu );
|
| 256 |
+
scope.domElement.removeEventListener( 'pointerdown', onPointerDown );
|
| 257 |
+
scope.domElement.removeEventListener( 'wheel', onMouseWheel );
|
| 258 |
+
scope.domElement.removeEventListener( 'touchstart', onTouchStart );
|
| 259 |
+
scope.domElement.removeEventListener( 'touchend', onTouchEnd );
|
| 260 |
+
scope.domElement.removeEventListener( 'touchmove', onTouchMove );
|
| 261 |
+
scope.domElement.ownerDocument.removeEventListener( 'pointermove', onPointerMove );
|
| 262 |
+
scope.domElement.ownerDocument.removeEventListener( 'pointerup', onPointerUp );
|
| 263 |
+
|
| 264 |
+
if ( scope._domElementKeyEvents !== null ) {
|
| 265 |
+
|
| 266 |
+
scope._domElementKeyEvents.removeEventListener( 'keydown', onKeyDown );
|
| 267 |
+
|
| 268 |
+
} //scope.dispatchEvent( { type: 'dispose' } ); // should this be added here?
|
| 269 |
+
|
| 270 |
+
}; //
|
| 271 |
+
// internals
|
| 272 |
+
//
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
const scope = this;
|
| 276 |
+
const STATE = {
|
| 277 |
+
NONE: - 1,
|
| 278 |
+
ROTATE: 0,
|
| 279 |
+
DOLLY: 1,
|
| 280 |
+
PAN: 2,
|
| 281 |
+
TOUCH_ROTATE: 3,
|
| 282 |
+
TOUCH_PAN: 4,
|
| 283 |
+
TOUCH_DOLLY_PAN: 5,
|
| 284 |
+
TOUCH_DOLLY_ROTATE: 6
|
| 285 |
+
};
|
| 286 |
+
let state = STATE.NONE;
|
| 287 |
+
const EPS = 0.000001; // current position in spherical coordinates
|
| 288 |
+
|
| 289 |
+
const spherical = new THREE.Spherical();
|
| 290 |
+
const sphericalDelta = new THREE.Spherical();
|
| 291 |
+
let scale = 1;
|
| 292 |
+
const panOffset = new THREE.Vector3();
|
| 293 |
+
let zoomChanged = false;
|
| 294 |
+
const rotateStart = new THREE.Vector2();
|
| 295 |
+
const rotateEnd = new THREE.Vector2();
|
| 296 |
+
const rotateDelta = new THREE.Vector2();
|
| 297 |
+
const panStart = new THREE.Vector2();
|
| 298 |
+
const panEnd = new THREE.Vector2();
|
| 299 |
+
const panDelta = new THREE.Vector2();
|
| 300 |
+
const dollyStart = new THREE.Vector2();
|
| 301 |
+
const dollyEnd = new THREE.Vector2();
|
| 302 |
+
const dollyDelta = new THREE.Vector2();
|
| 303 |
+
|
| 304 |
+
function getAutoRotationAngle() {
|
| 305 |
+
|
| 306 |
+
return 2 * Math.PI / 60 / 60 * scope.autoRotateSpeed;
|
| 307 |
+
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
function getZoomScale() {
|
| 311 |
+
|
| 312 |
+
return Math.pow( 0.95, scope.zoomSpeed );
|
| 313 |
+
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
function rotateLeft( angle ) {
|
| 317 |
+
|
| 318 |
+
sphericalDelta.theta -= angle;
|
| 319 |
+
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
function rotateUp( angle ) {
|
| 323 |
+
|
| 324 |
+
sphericalDelta.phi -= angle;
|
| 325 |
+
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
const panLeft = function () {
|
| 329 |
+
|
| 330 |
+
const v = new THREE.Vector3();
|
| 331 |
+
return function panLeft( distance, objectMatrix ) {
|
| 332 |
+
|
| 333 |
+
v.setFromMatrixColumn( objectMatrix, 0 ); // get X column of objectMatrix
|
| 334 |
+
|
| 335 |
+
v.multiplyScalar( - distance );
|
| 336 |
+
panOffset.add( v );
|
| 337 |
+
|
| 338 |
+
};
|
| 339 |
+
|
| 340 |
+
}();
|
| 341 |
+
|
| 342 |
+
const panUp = function () {
|
| 343 |
+
|
| 344 |
+
const v = new THREE.Vector3();
|
| 345 |
+
return function panUp( distance, objectMatrix ) {
|
| 346 |
+
|
| 347 |
+
if ( scope.screenSpacePanning === true ) {
|
| 348 |
+
|
| 349 |
+
v.setFromMatrixColumn( objectMatrix, 1 );
|
| 350 |
+
|
| 351 |
+
} else {
|
| 352 |
+
|
| 353 |
+
v.setFromMatrixColumn( objectMatrix, 0 );
|
| 354 |
+
v.crossVectors( scope.object.up, v );
|
| 355 |
+
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
v.multiplyScalar( distance );
|
| 359 |
+
panOffset.add( v );
|
| 360 |
+
|
| 361 |
+
};
|
| 362 |
+
|
| 363 |
+
}(); // deltaX and deltaY are in pixels; right and down are positive
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
const pan = function () {
|
| 367 |
+
|
| 368 |
+
const offset = new THREE.Vector3();
|
| 369 |
+
return function pan( deltaX, deltaY ) {
|
| 370 |
+
|
| 371 |
+
const element = scope.domElement;
|
| 372 |
+
|
| 373 |
+
if ( scope.object.isPerspectiveCamera ) {
|
| 374 |
+
|
| 375 |
+
// perspective
|
| 376 |
+
const position = scope.object.position;
|
| 377 |
+
offset.copy( position ).sub( scope.target );
|
| 378 |
+
let targetDistance = offset.length(); // half of the fov is center to top of screen
|
| 379 |
+
|
| 380 |
+
targetDistance *= Math.tan( scope.object.fov / 2 * Math.PI / 180.0 ); // we use only clientHeight here so aspect ratio does not distort speed
|
| 381 |
+
|
| 382 |
+
panLeft( 2 * deltaX * targetDistance / element.clientHeight, scope.object.matrix );
|
| 383 |
+
panUp( 2 * deltaY * targetDistance / element.clientHeight, scope.object.matrix );
|
| 384 |
+
|
| 385 |
+
} else if ( scope.object.isOrthographicCamera ) {
|
| 386 |
+
|
| 387 |
+
// orthographic
|
| 388 |
+
panLeft( deltaX * ( scope.object.right - scope.object.left ) / scope.object.zoom / element.clientWidth, scope.object.matrix );
|
| 389 |
+
panUp( deltaY * ( scope.object.top - scope.object.bottom ) / scope.object.zoom / element.clientHeight, scope.object.matrix );
|
| 390 |
+
|
| 391 |
+
} else {
|
| 392 |
+
|
| 393 |
+
// camera neither orthographic nor perspective
|
| 394 |
+
console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.' );
|
| 395 |
+
scope.enablePan = false;
|
| 396 |
+
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
};
|
| 400 |
+
|
| 401 |
+
}();
|
| 402 |
+
|
| 403 |
+
function dollyOut( dollyScale ) {
|
| 404 |
+
|
| 405 |
+
if ( scope.object.isPerspectiveCamera ) {
|
| 406 |
+
|
| 407 |
+
scale /= dollyScale;
|
| 408 |
+
|
| 409 |
+
} else if ( scope.object.isOrthographicCamera ) {
|
| 410 |
+
|
| 411 |
+
scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom * dollyScale ) );
|
| 412 |
+
scope.object.updateProjectionMatrix();
|
| 413 |
+
zoomChanged = true;
|
| 414 |
+
|
| 415 |
+
} else {
|
| 416 |
+
|
| 417 |
+
console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
|
| 418 |
+
scope.enableZoom = false;
|
| 419 |
+
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
function dollyIn( dollyScale ) {
|
| 425 |
+
|
| 426 |
+
if ( scope.object.isPerspectiveCamera ) {
|
| 427 |
+
|
| 428 |
+
scale *= dollyScale;
|
| 429 |
+
|
| 430 |
+
} else if ( scope.object.isOrthographicCamera ) {
|
| 431 |
+
|
| 432 |
+
scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / dollyScale ) );
|
| 433 |
+
scope.object.updateProjectionMatrix();
|
| 434 |
+
zoomChanged = true;
|
| 435 |
+
|
| 436 |
+
} else {
|
| 437 |
+
|
| 438 |
+
console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
|
| 439 |
+
scope.enableZoom = false;
|
| 440 |
+
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
} //
|
| 444 |
+
// event callbacks - update the object state
|
| 445 |
+
//
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
function handleMouseDownRotate( event ) {
|
| 449 |
+
|
| 450 |
+
rotateStart.set( event.clientX, event.clientY );
|
| 451 |
+
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
function handleMouseDownDolly( event ) {
|
| 455 |
+
|
| 456 |
+
dollyStart.set( event.clientX, event.clientY );
|
| 457 |
+
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
function handleMouseDownPan( event ) {
|
| 461 |
+
|
| 462 |
+
panStart.set( event.clientX, event.clientY );
|
| 463 |
+
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
function handleMouseMoveRotate( event ) {
|
| 467 |
+
|
| 468 |
+
rotateEnd.set( event.clientX, event.clientY );
|
| 469 |
+
rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed );
|
| 470 |
+
const element = scope.domElement;
|
| 471 |
+
rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height
|
| 472 |
+
|
| 473 |
+
rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight );
|
| 474 |
+
rotateStart.copy( rotateEnd );
|
| 475 |
+
scope.update();
|
| 476 |
+
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
function handleMouseMoveDolly( event ) {
|
| 480 |
+
|
| 481 |
+
dollyEnd.set( event.clientX, event.clientY );
|
| 482 |
+
dollyDelta.subVectors( dollyEnd, dollyStart );
|
| 483 |
+
|
| 484 |
+
if ( dollyDelta.y > 0 ) {
|
| 485 |
+
|
| 486 |
+
dollyOut( getZoomScale() );
|
| 487 |
+
|
| 488 |
+
} else if ( dollyDelta.y < 0 ) {
|
| 489 |
+
|
| 490 |
+
dollyIn( getZoomScale() );
|
| 491 |
+
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
dollyStart.copy( dollyEnd );
|
| 495 |
+
scope.update();
|
| 496 |
+
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
function handleMouseMovePan( event ) {
|
| 500 |
+
|
| 501 |
+
panEnd.set( event.clientX, event.clientY );
|
| 502 |
+
panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed );
|
| 503 |
+
pan( panDelta.x, panDelta.y );
|
| 504 |
+
panStart.copy( panEnd );
|
| 505 |
+
scope.update();
|
| 506 |
+
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
function handleMouseUp( ) { // no-op
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
function handleMouseWheel( event ) {
|
| 513 |
+
|
| 514 |
+
if ( event.deltaY < 0 ) {
|
| 515 |
+
|
| 516 |
+
dollyIn( getZoomScale() );
|
| 517 |
+
|
| 518 |
+
} else if ( event.deltaY > 0 ) {
|
| 519 |
+
|
| 520 |
+
dollyOut( getZoomScale() );
|
| 521 |
+
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
scope.update();
|
| 525 |
+
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
function handleKeyDown( event ) {
|
| 529 |
+
|
| 530 |
+
let needsUpdate = false;
|
| 531 |
+
|
| 532 |
+
switch ( event.code ) {
|
| 533 |
+
|
| 534 |
+
case scope.keys.UP:
|
| 535 |
+
pan( 0, scope.keyPanSpeed );
|
| 536 |
+
needsUpdate = true;
|
| 537 |
+
break;
|
| 538 |
+
|
| 539 |
+
case scope.keys.BOTTOM:
|
| 540 |
+
pan( 0, - scope.keyPanSpeed );
|
| 541 |
+
needsUpdate = true;
|
| 542 |
+
break;
|
| 543 |
+
|
| 544 |
+
case scope.keys.LEFT:
|
| 545 |
+
pan( scope.keyPanSpeed, 0 );
|
| 546 |
+
needsUpdate = true;
|
| 547 |
+
break;
|
| 548 |
+
|
| 549 |
+
case scope.keys.RIGHT:
|
| 550 |
+
pan( - scope.keyPanSpeed, 0 );
|
| 551 |
+
needsUpdate = true;
|
| 552 |
+
break;
|
| 553 |
+
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
if ( needsUpdate ) {
|
| 557 |
+
|
| 558 |
+
// prevent the browser from scrolling on cursor keys
|
| 559 |
+
event.preventDefault();
|
| 560 |
+
scope.update();
|
| 561 |
+
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
function handleTouchStartRotate( event ) {
|
| 567 |
+
|
| 568 |
+
if ( event.touches.length == 1 ) {
|
| 569 |
+
|
| 570 |
+
rotateStart.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
|
| 571 |
+
|
| 572 |
+
} else {
|
| 573 |
+
|
| 574 |
+
const x = 0.5 * ( event.touches[ 0 ].pageX + event.touches[ 1 ].pageX );
|
| 575 |
+
const y = 0.5 * ( event.touches[ 0 ].pageY + event.touches[ 1 ].pageY );
|
| 576 |
+
rotateStart.set( x, y );
|
| 577 |
+
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
function handleTouchStartPan( event ) {
|
| 583 |
+
|
| 584 |
+
if ( event.touches.length == 1 ) {
|
| 585 |
+
|
| 586 |
+
panStart.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
|
| 587 |
+
|
| 588 |
+
} else {
|
| 589 |
+
|
| 590 |
+
const x = 0.5 * ( event.touches[ 0 ].pageX + event.touches[ 1 ].pageX );
|
| 591 |
+
const y = 0.5 * ( event.touches[ 0 ].pageY + event.touches[ 1 ].pageY );
|
| 592 |
+
panStart.set( x, y );
|
| 593 |
+
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
function handleTouchStartDolly( event ) {
|
| 599 |
+
|
| 600 |
+
const dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX;
|
| 601 |
+
const dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY;
|
| 602 |
+
const distance = Math.sqrt( dx * dx + dy * dy );
|
| 603 |
+
dollyStart.set( 0, distance );
|
| 604 |
+
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
function handleTouchStartDollyPan( event ) {
|
| 608 |
+
|
| 609 |
+
if ( scope.enableZoom ) handleTouchStartDolly( event );
|
| 610 |
+
if ( scope.enablePan ) handleTouchStartPan( event );
|
| 611 |
+
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
function handleTouchStartDollyRotate( event ) {
|
| 615 |
+
|
| 616 |
+
if ( scope.enableZoom ) handleTouchStartDolly( event );
|
| 617 |
+
if ( scope.enableRotate ) handleTouchStartRotate( event );
|
| 618 |
+
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
function handleTouchMoveRotate( event ) {
|
| 622 |
+
|
| 623 |
+
if ( event.touches.length == 1 ) {
|
| 624 |
+
|
| 625 |
+
rotateEnd.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
|
| 626 |
+
|
| 627 |
+
} else {
|
| 628 |
+
|
| 629 |
+
const x = 0.5 * ( event.touches[ 0 ].pageX + event.touches[ 1 ].pageX );
|
| 630 |
+
const y = 0.5 * ( event.touches[ 0 ].pageY + event.touches[ 1 ].pageY );
|
| 631 |
+
rotateEnd.set( x, y );
|
| 632 |
+
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed );
|
| 636 |
+
const element = scope.domElement;
|
| 637 |
+
rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height
|
| 638 |
+
|
| 639 |
+
rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight );
|
| 640 |
+
rotateStart.copy( rotateEnd );
|
| 641 |
+
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
function handleTouchMovePan( event ) {
|
| 645 |
+
|
| 646 |
+
if ( event.touches.length == 1 ) {
|
| 647 |
+
|
| 648 |
+
panEnd.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
|
| 649 |
+
|
| 650 |
+
} else {
|
| 651 |
+
|
| 652 |
+
const x = 0.5 * ( event.touches[ 0 ].pageX + event.touches[ 1 ].pageX );
|
| 653 |
+
const y = 0.5 * ( event.touches[ 0 ].pageY + event.touches[ 1 ].pageY );
|
| 654 |
+
panEnd.set( x, y );
|
| 655 |
+
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed );
|
| 659 |
+
pan( panDelta.x, panDelta.y );
|
| 660 |
+
panStart.copy( panEnd );
|
| 661 |
+
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
function handleTouchMoveDolly( event ) {
|
| 665 |
+
|
| 666 |
+
const dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX;
|
| 667 |
+
const dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY;
|
| 668 |
+
const distance = Math.sqrt( dx * dx + dy * dy );
|
| 669 |
+
dollyEnd.set( 0, distance );
|
| 670 |
+
dollyDelta.set( 0, Math.pow( dollyEnd.y / dollyStart.y, scope.zoomSpeed ) );
|
| 671 |
+
dollyOut( dollyDelta.y );
|
| 672 |
+
dollyStart.copy( dollyEnd );
|
| 673 |
+
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
function handleTouchMoveDollyPan( event ) {
|
| 677 |
+
|
| 678 |
+
if ( scope.enableZoom ) handleTouchMoveDolly( event );
|
| 679 |
+
if ( scope.enablePan ) handleTouchMovePan( event );
|
| 680 |
+
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
function handleTouchMoveDollyRotate( event ) {
|
| 684 |
+
|
| 685 |
+
if ( scope.enableZoom ) handleTouchMoveDolly( event );
|
| 686 |
+
if ( scope.enableRotate ) handleTouchMoveRotate( event );
|
| 687 |
+
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
function handleTouchEnd( ) { // no-op
|
| 691 |
+
} //
|
| 692 |
+
// event handlers - FSM: listen for events and reset state
|
| 693 |
+
//
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
function onPointerDown( event ) {
|
| 697 |
+
|
| 698 |
+
if ( scope.enabled === false ) return;
|
| 699 |
+
|
| 700 |
+
switch ( event.pointerType ) {
|
| 701 |
+
|
| 702 |
+
case 'mouse':
|
| 703 |
+
case 'pen':
|
| 704 |
+
onMouseDown( event );
|
| 705 |
+
break;
|
| 706 |
+
// TODO touch
|
| 707 |
+
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
function onPointerMove( event ) {
|
| 713 |
+
|
| 714 |
+
if ( scope.enabled === false ) return;
|
| 715 |
+
|
| 716 |
+
switch ( event.pointerType ) {
|
| 717 |
+
|
| 718 |
+
case 'mouse':
|
| 719 |
+
case 'pen':
|
| 720 |
+
onMouseMove( event );
|
| 721 |
+
break;
|
| 722 |
+
// TODO touch
|
| 723 |
+
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
function onPointerUp( event ) {
|
| 729 |
+
|
| 730 |
+
switch ( event.pointerType ) {
|
| 731 |
+
|
| 732 |
+
case 'mouse':
|
| 733 |
+
case 'pen':
|
| 734 |
+
onMouseUp( event );
|
| 735 |
+
break;
|
| 736 |
+
// TODO touch
|
| 737 |
+
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
function onMouseDown( event ) {
|
| 743 |
+
|
| 744 |
+
// Prevent the browser from scrolling.
|
| 745 |
+
event.preventDefault(); // Manually set the focus since calling preventDefault above
|
| 746 |
+
// prevents the browser from setting it automatically.
|
| 747 |
+
|
| 748 |
+
scope.domElement.focus ? scope.domElement.focus() : window.focus();
|
| 749 |
+
let mouseAction;
|
| 750 |
+
|
| 751 |
+
switch ( event.button ) {
|
| 752 |
+
|
| 753 |
+
case 0:
|
| 754 |
+
mouseAction = scope.mouseButtons.LEFT;
|
| 755 |
+
break;
|
| 756 |
+
|
| 757 |
+
case 1:
|
| 758 |
+
mouseAction = scope.mouseButtons.MIDDLE;
|
| 759 |
+
break;
|
| 760 |
+
|
| 761 |
+
case 2:
|
| 762 |
+
mouseAction = scope.mouseButtons.RIGHT;
|
| 763 |
+
break;
|
| 764 |
+
|
| 765 |
+
default:
|
| 766 |
+
mouseAction = - 1;
|
| 767 |
+
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
switch ( mouseAction ) {
|
| 771 |
+
|
| 772 |
+
case THREE.MOUSE.DOLLY:
|
| 773 |
+
if ( scope.enableZoom === false ) return;
|
| 774 |
+
handleMouseDownDolly( event );
|
| 775 |
+
state = STATE.DOLLY;
|
| 776 |
+
break;
|
| 777 |
+
|
| 778 |
+
case THREE.MOUSE.ROTATE:
|
| 779 |
+
if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
|
| 780 |
+
|
| 781 |
+
if ( scope.enablePan === false ) return;
|
| 782 |
+
handleMouseDownPan( event );
|
| 783 |
+
state = STATE.PAN;
|
| 784 |
+
|
| 785 |
+
} else {
|
| 786 |
+
|
| 787 |
+
if ( scope.enableRotate === false ) return;
|
| 788 |
+
handleMouseDownRotate( event );
|
| 789 |
+
state = STATE.ROTATE;
|
| 790 |
+
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
break;
|
| 794 |
+
|
| 795 |
+
case THREE.MOUSE.PAN:
|
| 796 |
+
if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
|
| 797 |
+
|
| 798 |
+
if ( scope.enableRotate === false ) return;
|
| 799 |
+
handleMouseDownRotate( event );
|
| 800 |
+
state = STATE.ROTATE;
|
| 801 |
+
|
| 802 |
+
} else {
|
| 803 |
+
|
| 804 |
+
if ( scope.enablePan === false ) return;
|
| 805 |
+
handleMouseDownPan( event );
|
| 806 |
+
state = STATE.PAN;
|
| 807 |
+
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
break;
|
| 811 |
+
|
| 812 |
+
default:
|
| 813 |
+
state = STATE.NONE;
|
| 814 |
+
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
if ( state !== STATE.NONE ) {
|
| 818 |
+
|
| 819 |
+
scope.domElement.ownerDocument.addEventListener( 'pointermove', onPointerMove );
|
| 820 |
+
scope.domElement.ownerDocument.addEventListener( 'pointerup', onPointerUp );
|
| 821 |
+
scope.dispatchEvent( _startEvent );
|
| 822 |
+
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
function onMouseMove( event ) {
|
| 828 |
+
|
| 829 |
+
if ( scope.enabled === false ) return;
|
| 830 |
+
event.preventDefault();
|
| 831 |
+
|
| 832 |
+
switch ( state ) {
|
| 833 |
+
|
| 834 |
+
case STATE.ROTATE:
|
| 835 |
+
if ( scope.enableRotate === false ) return;
|
| 836 |
+
handleMouseMoveRotate( event );
|
| 837 |
+
break;
|
| 838 |
+
|
| 839 |
+
case STATE.DOLLY:
|
| 840 |
+
if ( scope.enableZoom === false ) return;
|
| 841 |
+
handleMouseMoveDolly( event );
|
| 842 |
+
break;
|
| 843 |
+
|
| 844 |
+
case STATE.PAN:
|
| 845 |
+
if ( scope.enablePan === false ) return;
|
| 846 |
+
handleMouseMovePan( event );
|
| 847 |
+
break;
|
| 848 |
+
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
function onMouseUp( event ) {
|
| 854 |
+
|
| 855 |
+
scope.domElement.ownerDocument.removeEventListener( 'pointermove', onPointerMove );
|
| 856 |
+
scope.domElement.ownerDocument.removeEventListener( 'pointerup', onPointerUp );
|
| 857 |
+
if ( scope.enabled === false ) return;
|
| 858 |
+
handleMouseUp( event );
|
| 859 |
+
scope.dispatchEvent( _endEvent );
|
| 860 |
+
state = STATE.NONE;
|
| 861 |
+
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
function onMouseWheel( event ) {
|
| 865 |
+
|
| 866 |
+
if ( scope.enabled === false || scope.enableZoom === false || state !== STATE.NONE && state !== STATE.ROTATE ) return;
|
| 867 |
+
event.preventDefault();
|
| 868 |
+
scope.dispatchEvent( _startEvent );
|
| 869 |
+
handleMouseWheel( event );
|
| 870 |
+
scope.dispatchEvent( _endEvent );
|
| 871 |
+
|
| 872 |
+
}
|
| 873 |
+
|
| 874 |
+
function onKeyDown( event ) {
|
| 875 |
+
|
| 876 |
+
if ( scope.enabled === false || scope.enablePan === false ) return;
|
| 877 |
+
handleKeyDown( event );
|
| 878 |
+
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
+
function onTouchStart( event ) {
|
| 882 |
+
|
| 883 |
+
if ( scope.enabled === false ) return;
|
| 884 |
+
event.preventDefault(); // prevent scrolling
|
| 885 |
+
|
| 886 |
+
switch ( event.touches.length ) {
|
| 887 |
+
|
| 888 |
+
case 1:
|
| 889 |
+
switch ( scope.touches.ONE ) {
|
| 890 |
+
|
| 891 |
+
case THREE.TOUCH.ROTATE:
|
| 892 |
+
if ( scope.enableRotate === false ) return;
|
| 893 |
+
handleTouchStartRotate( event );
|
| 894 |
+
state = STATE.TOUCH_ROTATE;
|
| 895 |
+
break;
|
| 896 |
+
|
| 897 |
+
case THREE.TOUCH.PAN:
|
| 898 |
+
if ( scope.enablePan === false ) return;
|
| 899 |
+
handleTouchStartPan( event );
|
| 900 |
+
state = STATE.TOUCH_PAN;
|
| 901 |
+
break;
|
| 902 |
+
|
| 903 |
+
default:
|
| 904 |
+
state = STATE.NONE;
|
| 905 |
+
|
| 906 |
+
}
|
| 907 |
+
|
| 908 |
+
break;
|
| 909 |
+
|
| 910 |
+
case 2:
|
| 911 |
+
switch ( scope.touches.TWO ) {
|
| 912 |
+
|
| 913 |
+
case THREE.TOUCH.DOLLY_PAN:
|
| 914 |
+
if ( scope.enableZoom === false && scope.enablePan === false ) return;
|
| 915 |
+
handleTouchStartDollyPan( event );
|
| 916 |
+
state = STATE.TOUCH_DOLLY_PAN;
|
| 917 |
+
break;
|
| 918 |
+
|
| 919 |
+
case THREE.TOUCH.DOLLY_ROTATE:
|
| 920 |
+
if ( scope.enableZoom === false && scope.enableRotate === false ) return;
|
| 921 |
+
handleTouchStartDollyRotate( event );
|
| 922 |
+
state = STATE.TOUCH_DOLLY_ROTATE;
|
| 923 |
+
break;
|
| 924 |
+
|
| 925 |
+
default:
|
| 926 |
+
state = STATE.NONE;
|
| 927 |
+
|
| 928 |
+
}
|
| 929 |
+
|
| 930 |
+
break;
|
| 931 |
+
|
| 932 |
+
default:
|
| 933 |
+
state = STATE.NONE;
|
| 934 |
+
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
if ( state !== STATE.NONE ) {
|
| 938 |
+
|
| 939 |
+
scope.dispatchEvent( _startEvent );
|
| 940 |
+
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
+
function onTouchMove( event ) {
|
| 946 |
+
|
| 947 |
+
if ( scope.enabled === false ) return;
|
| 948 |
+
event.preventDefault(); // prevent scrolling
|
| 949 |
+
|
| 950 |
+
switch ( state ) {
|
| 951 |
+
|
| 952 |
+
case STATE.TOUCH_ROTATE:
|
| 953 |
+
if ( scope.enableRotate === false ) return;
|
| 954 |
+
handleTouchMoveRotate( event );
|
| 955 |
+
scope.update();
|
| 956 |
+
break;
|
| 957 |
+
|
| 958 |
+
case STATE.TOUCH_PAN:
|
| 959 |
+
if ( scope.enablePan === false ) return;
|
| 960 |
+
handleTouchMovePan( event );
|
| 961 |
+
scope.update();
|
| 962 |
+
break;
|
| 963 |
+
|
| 964 |
+
case STATE.TOUCH_DOLLY_PAN:
|
| 965 |
+
if ( scope.enableZoom === false && scope.enablePan === false ) return;
|
| 966 |
+
handleTouchMoveDollyPan( event );
|
| 967 |
+
scope.update();
|
| 968 |
+
break;
|
| 969 |
+
|
| 970 |
+
case STATE.TOUCH_DOLLY_ROTATE:
|
| 971 |
+
if ( scope.enableZoom === false && scope.enableRotate === false ) return;
|
| 972 |
+
handleTouchMoveDollyRotate( event );
|
| 973 |
+
scope.update();
|
| 974 |
+
break;
|
| 975 |
+
|
| 976 |
+
default:
|
| 977 |
+
state = STATE.NONE;
|
| 978 |
+
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
function onTouchEnd( event ) {
|
| 984 |
+
|
| 985 |
+
if ( scope.enabled === false ) return;
|
| 986 |
+
handleTouchEnd( event );
|
| 987 |
+
scope.dispatchEvent( _endEvent );
|
| 988 |
+
state = STATE.NONE;
|
| 989 |
+
|
| 990 |
+
}
|
| 991 |
+
|
| 992 |
+
function onContextMenu( event ) {
|
| 993 |
+
|
| 994 |
+
if ( scope.enabled === false ) return;
|
| 995 |
+
event.preventDefault();
|
| 996 |
+
|
| 997 |
+
} //
|
| 998 |
+
|
| 999 |
+
|
| 1000 |
+
scope.domElement.addEventListener( 'contextmenu', onContextMenu );
|
| 1001 |
+
scope.domElement.addEventListener( 'pointerdown', onPointerDown );
|
| 1002 |
+
scope.domElement.addEventListener( 'wheel', onMouseWheel, {
|
| 1003 |
+
passive: false
|
| 1004 |
+
} );
|
| 1005 |
+
scope.domElement.addEventListener( 'touchstart', onTouchStart, {
|
| 1006 |
+
passive: false
|
| 1007 |
+
} );
|
| 1008 |
+
scope.domElement.addEventListener( 'touchend', onTouchEnd );
|
| 1009 |
+
scope.domElement.addEventListener( 'touchmove', onTouchMove, {
|
| 1010 |
+
passive: false
|
| 1011 |
+
} ); // force an update at start
|
| 1012 |
+
|
| 1013 |
+
this.update();
|
| 1014 |
+
|
| 1015 |
+
}
|
| 1016 |
+
|
| 1017 |
+
} // This set of controls performs orbiting, dollying (zooming), and panning.
|
| 1018 |
+
// Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default).
|
| 1019 |
+
// This is very similar to OrbitControls, another set of touch behavior
|
| 1020 |
+
//
|
| 1021 |
+
// Orbit - right mouse, or left mouse + ctrl/meta/shiftKey / touch: two-finger rotate
|
| 1022 |
+
// Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish
|
| 1023 |
+
// Pan - left mouse, or arrow keys / touch: one-finger move
|
| 1024 |
+
|
| 1025 |
+
|
| 1026 |
+
class MapControls extends OrbitControls {
|
| 1027 |
+
|
| 1028 |
+
constructor( object, domElement ) {
|
| 1029 |
+
|
| 1030 |
+
super( object, domElement );
|
| 1031 |
+
this.screenSpacePanning = false; // pan orthogonal to world-space direction camera.up
|
| 1032 |
+
|
| 1033 |
+
this.mouseButtons.LEFT = THREE.MOUSE.PAN;
|
| 1034 |
+
this.mouseButtons.RIGHT = THREE.MOUSE.ROTATE;
|
| 1035 |
+
this.touches.ONE = THREE.TOUCH.PAN;
|
| 1036 |
+
this.touches.TWO = THREE.TOUCH.DOLLY_ROTATE;
|
| 1037 |
+
|
| 1038 |
+
}
|
| 1039 |
+
|
| 1040 |
+
}
|
| 1041 |
+
|
| 1042 |
+
THREE.MapControls = MapControls;
|
| 1043 |
+
THREE.OrbitControls = OrbitControls;
|
| 1044 |
+
|
| 1045 |
+
} )();
|
web/vendor/three.min.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/verdict-river.html
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Sentra · Verdict River</title>
|
| 7 |
+
<!--
|
| 8 |
+
Sentra PALANTIR-CLASS Verdict River.
|
| 9 |
+
Author: Yachay <yachay@szlholdings.dev>
|
| 10 |
+
Co-Authored-By: Perplexity Computer Agent <agent@perplexity.ai>
|
| 11 |
+
Doctrine v11 LOCKED 749/14/163 · Λ = Conjecture 1 (NOT a theorem).
|
| 12 |
+
|
| 13 |
+
Verdicts flow inbound (left) -> outbound (right):
|
| 14 |
+
* ALLOW pass through smoothly (green).
|
| 15 |
+
* REVIEW slow down inside a yellow halo (held mid-river for human eyeball).
|
| 16 |
+
* DENY hit the containment wall and flash red (fail-CLOSED — never passes).
|
| 17 |
+
Live SSE @ ~10 verdicts/sec. Click a verdict -> its full inspect dossier.
|
| 18 |
+
|
| 19 |
+
Patterns stolen (cited): Palantir Gotham (entity overlay + click dossier),
|
| 20 |
+
Splunk (high-cardinality field bag per card). Sovereign: Three.js vendored
|
| 21 |
+
locally under /vendor; no CDN.
|
| 22 |
+
-->
|
| 23 |
+
<script src="/vendor/three.min.js"></script>
|
| 24 |
+
<style>
|
| 25 |
+
:root{ --bg:#05080f; --panel:rgba(10,16,26,0.85); --line:#1d2a3e; --txt:#cfe3ff;
|
| 26 |
+
--green:#22e07a; --yellow:#ffd54f; --red:#ff3b3b; --mut:#7790ad; }
|
| 27 |
+
*{box-sizing:border-box}
|
| 28 |
+
html,body{margin:0;height:100%;background:var(--bg);color:var(--txt);overflow:hidden;
|
| 29 |
+
font-family:"SF Mono",ui-monospace,Menlo,Consolas,monospace}
|
| 30 |
+
#river{position:fixed;inset:0}
|
| 31 |
+
.hdr{position:fixed;top:0;left:0;right:0;padding:10px 16px;z-index:5;display:flex;
|
| 32 |
+
align-items:center;gap:14px;background:linear-gradient(#05080fcc,#05080f00)}
|
| 33 |
+
.hdr h1{font-size:15px;margin:0;letter-spacing:2px;color:#eaf3ff;font-weight:600}
|
| 34 |
+
.hdr .doc{font-size:10px;color:var(--mut);letter-spacing:1px}
|
| 35 |
+
.tag{font-size:9px;padding:2px 7px;border:1px solid var(--line);border-radius:10px;color:var(--mut)}
|
| 36 |
+
a.nav{color:#5fb0ff;font-size:11px;text-decoration:none;border:1px solid var(--line);padding:3px 9px;border-radius:8px}
|
| 37 |
+
.ends{position:fixed;top:46px;z-index:5;font-size:11px;color:var(--mut);letter-spacing:1px}
|
| 38 |
+
#inb{left:18px} #outb{right:18px}
|
| 39 |
+
.stat{position:fixed;bottom:12px;left:18px;z-index:5;font-size:11px;color:var(--mut)}
|
| 40 |
+
.stat b{color:#eaf3ff}
|
| 41 |
+
.conn{position:fixed;bottom:12px;right:18px;z-index:5;font-size:10px;color:var(--mut)}
|
| 42 |
+
.conn b{color:var(--green)}
|
| 43 |
+
.legend{position:fixed;bottom:34px;left:18px;z-index:5;font-size:11px}
|
| 44 |
+
.legend span{margin-right:14px}
|
| 45 |
+
#card{position:fixed;top:60px;right:18px;width:360px;max-height:80vh;overflow:auto;z-index:9;
|
| 46 |
+
background:var(--panel);border:1px solid #2a3b55;border-radius:12px;padding:14px 16px;
|
| 47 |
+
display:none;backdrop-filter:blur(8px);box-shadow:0 18px 60px #000a}
|
| 48 |
+
#card h2{font-size:13px;margin:0 0 8px;letter-spacing:1px}
|
| 49 |
+
#card .kv{display:flex;justify-content:space-between;gap:10px;font-size:11px;padding:3px 0;border-bottom:1px solid #15202f}
|
| 50 |
+
#card .kv span:first-child{color:var(--mut)} #card .kv span:last-child{color:#eaf3ff;text-align:right;word-break:break-all}
|
| 51 |
+
#card .close{position:absolute;top:10px;right:12px;cursor:pointer;color:var(--mut);font-size:14px}
|
| 52 |
+
#card .receipt{margin-top:8px;font-size:10px;color:#9fd;background:#0c1622;border:1px solid #14283a;border-radius:6px;padding:7px;word-break:break-all}
|
| 53 |
+
</style>
|
| 54 |
+
</head>
|
| 55 |
+
<body>
|
| 56 |
+
<div id="river"></div>
|
| 57 |
+
<div class="hdr">
|
| 58 |
+
<h1>SENTRA · VERDICT RIVER</h1>
|
| 59 |
+
<span class="tag">IMMUNE SENTINEL</span>
|
| 60 |
+
<span class="doc">Doctrine v11 · 749/14/163 LOCKED · Λ Conjecture 1</span>
|
| 61 |
+
<span style="flex:1"></span>
|
| 62 |
+
<a class="nav" href="/threat-globe">→ Threat Globe</a>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="ends" id="inb">◀ INBOUND</div>
|
| 65 |
+
<div class="ends" id="outb">OUTBOUND ▶ · containment wall</div>
|
| 66 |
+
<div class="legend">
|
| 67 |
+
<span style="color:var(--green)">● ALLOW pass-through</span>
|
| 68 |
+
<span style="color:var(--yellow)">● REVIEW slows + halo</span>
|
| 69 |
+
<span style="color:var(--red)">● DENY hits wall + flash</span>
|
| 70 |
+
</div>
|
| 71 |
+
<div class="stat" id="stat">flow <b id="vc">0</b> · ALLOW <b id="ca" style="color:var(--green)">0</b>
|
| 72 |
+
· REVIEW <b id="cr" style="color:var(--yellow)">0</b> · DENY <b id="cd" style="color:var(--red)">0</b></div>
|
| 73 |
+
<div class="conn">SSE <b id="connstate">connecting…</b></div>
|
| 74 |
+
<div id="card"><span class="close" onclick="document.getElementById('card').style.display='none'">✕</span>
|
| 75 |
+
<h2>VERDICT INSPECT · <span id="cardv"></span></h2><div id="cardbody"></div></div>
|
| 76 |
+
|
| 77 |
+
<script>
|
| 78 |
+
const RV=document.getElementById('river');
|
| 79 |
+
const scene=new THREE.Scene(); scene.fog=new THREE.FogExp2(0x05080f,0.03);
|
| 80 |
+
const camera=new THREE.PerspectiveCamera(50, innerWidth/innerHeight, 0.1, 500);
|
| 81 |
+
camera.position.set(0, 11, 19); camera.lookAt(0,0,0);
|
| 82 |
+
const renderer=new THREE.WebGLRenderer({antialias:true,alpha:true});
|
| 83 |
+
renderer.setPixelRatio(Math.min(2,devicePixelRatio)); renderer.setSize(innerWidth,innerHeight);
|
| 84 |
+
RV.appendChild(renderer.domElement);
|
| 85 |
+
scene.add(new THREE.AmbientLight(0x5570a0,0.9));
|
| 86 |
+
const dir=new THREE.DirectionalLight(0xbcd8ff,0.8); dir.position.set(0,12,8); scene.add(dir);
|
| 87 |
+
|
| 88 |
+
const LEN=46, WID=10;
|
| 89 |
+
// ---- river bed (animated flowing shader-ish via scrolling texture) ----
|
| 90 |
+
function makeFlowTex(){
|
| 91 |
+
const c=document.createElement('canvas'); c.width=512; c.height=128; const x=c.getContext('2d');
|
| 92 |
+
x.fillStyle='#06121f'; x.fillRect(0,0,512,128);
|
| 93 |
+
for(let i=0;i<60;i++){ x.strokeStyle='rgba(60,120,180,'+(0.04+Math.random()*0.12)+')'; x.lineWidth=1+Math.random()*2;
|
| 94 |
+
const y=Math.random()*128; x.beginPath(); x.moveTo(0,y); for(let xx=0;xx<=512;xx+=32) x.lineTo(xx,y+Math.sin(xx*0.05)*4); x.stroke(); }
|
| 95 |
+
const t=new THREE.CanvasTexture(c); t.wrapS=t.wrapT=THREE.RepeatWrapping; t.repeat.set(4,1); return t;
|
| 96 |
+
}
|
| 97 |
+
const flowTex=makeFlowTex();
|
| 98 |
+
const bed=new THREE.Mesh(new THREE.PlaneGeometry(LEN,WID,1,1),
|
| 99 |
+
new THREE.MeshPhongMaterial({map:flowTex, color:0x0a2238, shininess:40, specular:0x1a4a6a,
|
| 100 |
+
transparent:true, opacity:0.95}));
|
| 101 |
+
bed.rotation.x=-Math.PI/2; scene.add(bed);
|
| 102 |
+
// banks
|
| 103 |
+
[-1,1].forEach(s=>{ const b=new THREE.Mesh(new THREE.BoxGeometry(LEN,0.6,0.5),
|
| 104 |
+
new THREE.MeshLambertMaterial({color:0x12243a})); b.position.set(0,0.3,s*WID/2); scene.add(b); });
|
| 105 |
+
// containment wall (right / outbound end)
|
| 106 |
+
const wall=new THREE.Mesh(new THREE.BoxGeometry(0.8,3.2,WID),
|
| 107 |
+
new THREE.MeshPhongMaterial({color:0x331016, emissive:0x440000, emissiveIntensity:0.3, transparent:true, opacity:0.85}));
|
| 108 |
+
wall.position.set(LEN/2-0.2,1.5,0); scene.add(wall);
|
| 109 |
+
let wallFlash=0;
|
| 110 |
+
|
| 111 |
+
// ---- verdict bodies ----
|
| 112 |
+
const COL={ALLOW:0x22e07a, REVIEW:0xffd54f, DENY:0xff3b3b};
|
| 113 |
+
const flowing=[]; // {mesh, halo, v, x, lane, speed, state, denyHit}
|
| 114 |
+
function spawn(v){
|
| 115 |
+
const col=COL[v.status]||0x22e07a;
|
| 116 |
+
const geo=new THREE.SphereGeometry(v.status==='DENY'?0.42:0.34, 16,16);
|
| 117 |
+
const mat=new THREE.MeshPhongMaterial({color:col, emissive:col, emissiveIntensity:0.35, shininess:60});
|
| 118 |
+
const m=new THREE.Mesh(geo,mat);
|
| 119 |
+
const lane=(Math.random()-0.5)*(WID-2);
|
| 120 |
+
m.position.set(-LEN/2+1, 0.5, lane); m.userData=v; scene.add(m);
|
| 121 |
+
let halo=null;
|
| 122 |
+
if(v.status==='REVIEW'){
|
| 123 |
+
halo=new THREE.Mesh(new THREE.TorusGeometry(0.6,0.06,8,24),
|
| 124 |
+
new THREE.MeshBasicMaterial({color:0xffd54f, transparent:true, opacity:0.8}));
|
| 125 |
+
halo.rotation.x=Math.PI/2; m.add(halo);
|
| 126 |
+
}
|
| 127 |
+
const speed = v.status==='ALLOW'? 0.16+Math.random()*0.05
|
| 128 |
+
: v.status==='REVIEW'? 0.055
|
| 129 |
+
: 0.20; // DENY rushes then hits wall
|
| 130 |
+
flowing.push({mesh:m, halo, v, lane, speed, state:v.status, denyHit:false, born:performance.now()});
|
| 131 |
+
if(flowing.length>140){ const o=flowing.shift(); scene.remove(o.mesh); }
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
// counts
|
| 135 |
+
let cA=0,cR=0,cD=0,cTot=0;
|
| 136 |
+
function bump(s){cTot++; if(s==='ALLOW')cA++; else if(s==='REVIEW')cR++; else cD++;
|
| 137 |
+
vc.textContent=cTot; ca.textContent=cA; cr.textContent=cR; cd.textContent=cD;}
|
| 138 |
+
|
| 139 |
+
// click -> card
|
| 140 |
+
const ray=new THREE.Raycaster(), mouse=new THREE.Vector2();
|
| 141 |
+
renderer.domElement.addEventListener('click', e=>{
|
| 142 |
+
mouse.x=(e.clientX/innerWidth)*2-1; mouse.y=-(e.clientY/innerHeight)*2+1;
|
| 143 |
+
ray.setFromCamera(mouse,camera);
|
| 144 |
+
const hits=ray.intersectObjects(flowing.map(f=>f.mesh));
|
| 145 |
+
if(hits.length) openCard(hits[0].object.userData);
|
| 146 |
+
});
|
| 147 |
+
function openCard(v){
|
| 148 |
+
document.getElementById('cardv').textContent=v.status;
|
| 149 |
+
document.getElementById('cardv').style.color=v.status==='ALLOW'?'#22e07a':v.status==='REVIEW'?'#ffd54f':'#ff3b3b';
|
| 150 |
+
const rows=[['verdict (raw)',v.verdict],['status',v.status],['origin',v.region],
|
| 151 |
+
['source ip',v.ip||'—'],['category',v.category],
|
| 152 |
+
['gates passed',(v.gates_passed??'—')+' / '+(v.gates_total??8)],
|
| 153 |
+
['rejected gates',(v.rejected_gates&&v.rejected_gates.length)?v.rejected_gates.join(', '):'none'],
|
| 154 |
+
['λ geo-mean',v.lambda_geo??'—'],['actor',v.actor||'—'],['source',v.source||'live'],
|
| 155 |
+
['signed',v.signed?'true':'false'],['timestamp',v.ts],['action',(v.action_preview||'').slice(0,120)]];
|
| 156 |
+
document.getElementById('cardbody').innerHTML=
|
| 157 |
+
rows.map(r=>`<div class="kv"><span>${r[0]}</span><span>${r[1]}</span></div>`).join('')
|
| 158 |
+
+`<div class="receipt"><b>Khipu receipt (DSSE)</b><br>${v.receipt_sha||'(unsigned — no cosign secret)'}</div>`;
|
| 159 |
+
document.getElementById('card').style.display='block';
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// ---- SSE ----
|
| 163 |
+
function connect(){
|
| 164 |
+
const es=new EventSource('/api/sentra/v4/threat-feed');
|
| 165 |
+
es.addEventListener('snapshot', e=>{ const d=JSON.parse(e.data);
|
| 166 |
+
document.getElementById('connstate').textContent='live';
|
| 167 |
+
d.verdicts.slice(-20).reverse().forEach(v=>{ spawn(v); bump(v.status); }); });
|
| 168 |
+
es.addEventListener('verdict', e=>{ const v=JSON.parse(e.data); spawn(v); bump(v.status); });
|
| 169 |
+
es.onopen=()=>document.getElementById('connstate').textContent='live';
|
| 170 |
+
es.onerror=()=>{ document.getElementById('connstate').textContent='reconnecting…'; es.close(); setTimeout(connect,2500); };
|
| 171 |
+
}
|
| 172 |
+
connect();
|
| 173 |
+
|
| 174 |
+
// ---- animate ----
|
| 175 |
+
function animate(t){
|
| 176 |
+
requestAnimationFrame(animate);
|
| 177 |
+
flowTex.offset.x = (t*0.00008)%1;
|
| 178 |
+
const wallX=LEN/2-0.9, holdX=2; // REVIEW holds near middle
|
| 179 |
+
for(let i=flowing.length-1;i>=0;i--){
|
| 180 |
+
const f=flowing[i]; const m=f.mesh;
|
| 181 |
+
if(f.state==='REVIEW'){
|
| 182 |
+
// slow toward hold zone, linger, then drift out
|
| 183 |
+
if(m.position.x < holdX-3) m.position.x += f.speed*1.6;
|
| 184 |
+
else if(m.position.x < holdX) m.position.x += f.speed*0.4;
|
| 185 |
+
else m.position.x += 0.02;
|
| 186 |
+
if(f.halo){ f.halo.rotation.z=t*0.004; f.halo.material.opacity=0.5+0.4*Math.sin(t*0.005); }
|
| 187 |
+
m.material.emissiveIntensity=0.4+0.3*Math.sin(t*0.006);
|
| 188 |
+
} else if(f.state==='DENY'){
|
| 189 |
+
if(!f.denyHit && m.position.x < wallX){ m.position.x += f.speed; }
|
| 190 |
+
else if(!f.denyHit){ f.denyHit=true; wallFlash=1; m.material.emissiveIntensity=1.4;
|
| 191 |
+
m.scale.setScalar(1.5); }
|
| 192 |
+
else { // bounce back and sink
|
| 193 |
+
m.position.x -= 0.05; m.position.y -= 0.02; m.material.opacity=Math.max(0,m.material.opacity-0.01);
|
| 194 |
+
m.material.transparent=true;
|
| 195 |
+
if(m.position.y < -1){ scene.remove(m); flowing.splice(i,1); continue; }
|
| 196 |
+
}
|
| 197 |
+
} else { // ALLOW
|
| 198 |
+
m.position.x += f.speed;
|
| 199 |
+
if(m.position.x > LEN/2+2){ scene.remove(m); flowing.splice(i,1); continue; }
|
| 200 |
+
}
|
| 201 |
+
m.position.y = 0.5 + Math.sin(t*0.004 + f.lane)*0.12;
|
| 202 |
+
}
|
| 203 |
+
if(wallFlash>0){ wallFlash=Math.max(0,wallFlash-0.03);
|
| 204 |
+
wall.material.emissiveIntensity=0.3+wallFlash*1.6; }
|
| 205 |
+
renderer.render(scene,camera);
|
| 206 |
+
}
|
| 207 |
+
animate();
|
| 208 |
+
addEventListener('resize', ()=>{ camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix();
|
| 209 |
+
renderer.setSize(innerWidth,innerHeight); });
|
| 210 |
+
</script>
|
| 211 |
+
</body>
|
| 212 |
+
</html>
|