Initial diophantine sphere component
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Diophantine Sphere Component Conversation Log
|
||||||
|
|
||||||
|
Created: June 7, 2026
|
||||||
|
|
||||||
|
Current folder before relocation:
|
||||||
|
|
||||||
|
`/Users/jenstandstad/Documents/glitch_diophantine_sphere`
|
||||||
|
|
||||||
|
## Initial Setup
|
||||||
|
|
||||||
|
The project began as an empty git folder. The sibling project `../glitch-bubble-simulator` was inspected and used as the structural template. Its standalone Glitch component shape was copied:
|
||||||
|
|
||||||
|
- `src/Component.tsx`
|
||||||
|
- `src/index.tsx`
|
||||||
|
- `src/dev.tsx`
|
||||||
|
- `src/styles.module.css`
|
||||||
|
- `src/types.ts`
|
||||||
|
- `vite.config.ts`
|
||||||
|
- `tsconfig.json`
|
||||||
|
- `package.json`
|
||||||
|
- `glitch.manifest.json`
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
No sibling `SKILL.md` file was found. The readable sibling docs were the README files for `glitch-bubble-simulator` and `glitch_demographic_wave`.
|
||||||
|
|
||||||
|
## Core Visualization
|
||||||
|
|
||||||
|
The component was renamed and rebuilt as `diophantine-sphere`.
|
||||||
|
|
||||||
|
The initial mathematical model:
|
||||||
|
|
||||||
|
`|x| + |y| + |z| = C`
|
||||||
|
|
||||||
|
where `x`, `y`, and `z` are integers.
|
||||||
|
|
||||||
|
Each integer vector is reduced by `gcd(|x|, |y|, |z|)` so collinear integer vectors share one distinct ray. Points are then placed along those rays.
|
||||||
|
|
||||||
|
The component uses Three.js for the 3D scene.
|
||||||
|
|
||||||
|
## Controls Added
|
||||||
|
|
||||||
|
The first controls were:
|
||||||
|
|
||||||
|
- allow negative values
|
||||||
|
- radius
|
||||||
|
- complexity
|
||||||
|
|
||||||
|
Then these were extended into the current control set:
|
||||||
|
|
||||||
|
- `Surface`, `Single C`, `Range 1..C` mode switch
|
||||||
|
- `Allow negative values`
|
||||||
|
- `Normalize distance`
|
||||||
|
- `D` for distance / radius
|
||||||
|
- `C` for complexity
|
||||||
|
- `R` for dot radius
|
||||||
|
|
||||||
|
The public parameter labels in `src/index.tsx` were simplified to `D`, `C`, and `R`.
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
### Surface
|
||||||
|
|
||||||
|
Shows a continuous surface, representing infinitely many points.
|
||||||
|
|
||||||
|
When `Normalize distance` is checked:
|
||||||
|
|
||||||
|
`x² + y² + z² = r²`
|
||||||
|
|
||||||
|
This is the round Euclidean sphere.
|
||||||
|
|
||||||
|
When `Normalize distance` is unchecked:
|
||||||
|
|
||||||
|
`|x| + |y| + |z| = r`
|
||||||
|
|
||||||
|
This is the continuous L1 surface, rendered as an octahedral surface.
|
||||||
|
|
||||||
|
### Single C
|
||||||
|
|
||||||
|
Shows only vectors satisfying:
|
||||||
|
|
||||||
|
`|x| + |y| + |z| = C`
|
||||||
|
|
||||||
|
This mode is intended to reveal the triangular grid pattern.
|
||||||
|
|
||||||
|
### Range 1..C
|
||||||
|
|
||||||
|
Shows all complexities from `1` through `C`.
|
||||||
|
|
||||||
|
Overlapping rays are merged. Point color indicates how many vectors collapse to the same ray direction.
|
||||||
|
|
||||||
|
## Distance Normalization
|
||||||
|
|
||||||
|
When `Normalize distance` is checked, ray points are placed on the Euclidean sphere:
|
||||||
|
|
||||||
|
`sqrt(x² + y² + z²) = D`
|
||||||
|
|
||||||
|
When unchecked, ray points are scaled until:
|
||||||
|
|
||||||
|
`|x| + |y| + |z| = D`
|
||||||
|
|
||||||
|
This lets the user compare the round Euclidean projection with the taxicab / complexity boundary.
|
||||||
|
|
||||||
|
## Color and Style
|
||||||
|
|
||||||
|
The visual style was shifted toward a green/yellow retro-futuristic palette.
|
||||||
|
|
||||||
|
The overlap color ramp now runs:
|
||||||
|
|
||||||
|
green -> yellow -> amber
|
||||||
|
|
||||||
|
The layout was simplified:
|
||||||
|
|
||||||
|
- explanatory footer hidden
|
||||||
|
- small kicker labels hidden
|
||||||
|
- controls are on the left
|
||||||
|
- square 3D viewport is on the right
|
||||||
|
- count boxes were moved out of the header flow so they do not shrink the viewport
|
||||||
|
- viewport remains square
|
||||||
|
|
||||||
|
The latest measured browser viewport size was approximately:
|
||||||
|
|
||||||
|
`643 x 643`
|
||||||
|
|
||||||
|
## 3D Scene Interaction
|
||||||
|
|
||||||
|
The viewport supports:
|
||||||
|
|
||||||
|
- drag rotation
|
||||||
|
- mouse wheel zoom
|
||||||
|
- pinch zoom using two-pointer distance tracking
|
||||||
|
|
||||||
|
Idle auto-rotation was reduced to 25% of the original speed.
|
||||||
|
|
||||||
|
After user drag or zoom interaction, idle rotation restarts from zero and slowly accelerates back.
|
||||||
|
|
||||||
|
## Surface Shading
|
||||||
|
|
||||||
|
The initial continuous sphere looked flat. The surface shell was changed from unlit `MeshBasicMaterial` to lit `MeshLambertMaterial`.
|
||||||
|
|
||||||
|
Lighting added:
|
||||||
|
|
||||||
|
- ambient light
|
||||||
|
- warm green/yellow key light
|
||||||
|
- green fill light
|
||||||
|
- pale rim light
|
||||||
|
|
||||||
|
The CSS viewport grid overlay was removed because it created vertical bars on the right side of the scene.
|
||||||
|
|
||||||
|
## Current Verification State
|
||||||
|
|
||||||
|
The following checks were run repeatedly after changes:
|
||||||
|
|
||||||
|
- `npm install`
|
||||||
|
- `npm run build`
|
||||||
|
- local Vite preview at `http://127.0.0.1:3003/`
|
||||||
|
- browser DOM/layout checks
|
||||||
|
- browser console error checks
|
||||||
|
|
||||||
|
The current build passes:
|
||||||
|
|
||||||
|
`npm run build`
|
||||||
|
|
||||||
|
The latest bundle exists at:
|
||||||
|
|
||||||
|
`dist/diophantine-sphere.iife.js`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
The project is currently located in the wrong folder and will be moved. This log was created so the design and implementation context travels with the folder.
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Diophantine Sphere
|
||||||
|
|
||||||
|
Standalone Glitch University component that renders the teaching sequence from a continuous sphere to restricted integer rays. It can show a perfect surface `x² + y² + z² = r²`, an L1 surface `|x| + |y| + |z| = r`, a single complexity `|x| + |y| + |z| = C`, or the combined range `|x| + |y| + |z| in [1, C]`. Overlapping rays are combined and coloured by multiplicity.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
- `npm install`
|
||||||
|
- `npm run dev`
|
||||||
|
- `npm run build`
|
||||||
|
|
||||||
|
The component follows the Glitch Component contract and registers itself as `window.GlitchComponents["diophantine-sphere"]` when loaded from the built IIFE bundle.
|
||||||
Vendored
+3978
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"componentId": "diophantine-sphere",
|
||||||
|
"displayName": "Diophantine Sphere",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"folderName": "glitch_diophantine_sphere",
|
||||||
|
"packageName": "@glitch-components/diophantine-sphere",
|
||||||
|
"entry": "dist/diophantine-sphere.js",
|
||||||
|
"source": "src/index.tsx",
|
||||||
|
"tags": [
|
||||||
|
"glitch-component",
|
||||||
|
"diophantine",
|
||||||
|
"sphere",
|
||||||
|
"integer",
|
||||||
|
"visualization"
|
||||||
|
]
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Diophantine Sphere</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/dev.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2994
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "@glitch-components/diophantine-sphere",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Diophantine Sphere glitch component for Glitch University",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/diophantine-sphere.js",
|
||||||
|
"module": "./dist/diophantine-sphere.js",
|
||||||
|
"types": "./src/types.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/diophantine-sphere.js",
|
||||||
|
"types": "./src/types.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"src/types.ts",
|
||||||
|
"glitch.manifest.json"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"three": "^0.181.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"@types/three": "^0.181.0",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
|
"leva": "^0.10.1",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^6.4.1",
|
||||||
|
"vite-plugin-css-injected-by-js": "^3.5.2"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"glitch",
|
||||||
|
"diophantine",
|
||||||
|
"sphere",
|
||||||
|
"integer",
|
||||||
|
"visualization"
|
||||||
|
],
|
||||||
|
"author": "Glitch.university",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,845 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import type { CSSProperties, PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import styles from './styles.module.css'
|
||||||
|
import type { GlitchComponentProps } from './types'
|
||||||
|
|
||||||
|
interface ComponentParams {
|
||||||
|
allowNegative?: boolean
|
||||||
|
normalizeDistance?: boolean
|
||||||
|
radius?: number
|
||||||
|
complexity?: number
|
||||||
|
dotRadius?: number
|
||||||
|
complexityMode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComplexityMode = 'surface' | 'single' | 'range'
|
||||||
|
|
||||||
|
interface RayBucket {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
z: number
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
rotationX: number
|
||||||
|
rotationY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PointerPoint {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PinchState {
|
||||||
|
distance: number
|
||||||
|
cameraDistance: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
||||||
|
const numberParam = (value: unknown, fallback: number) => (typeof value === 'number' ? value : fallback)
|
||||||
|
const MIN_CAMERA_DISTANCE = 6.8
|
||||||
|
const MAX_CAMERA_DISTANCE = 22
|
||||||
|
const DEFAULT_CAMERA_DISTANCE = 13
|
||||||
|
const IDLE_ROTATION_SPEED = 0.0005
|
||||||
|
const MAX_COMPLEXITY = 96
|
||||||
|
const MAX_DOT_RADIUS = 1.2
|
||||||
|
|
||||||
|
const gcd2 = (a: number, b: number): number => {
|
||||||
|
let left = Math.abs(a)
|
||||||
|
let right = Math.abs(b)
|
||||||
|
|
||||||
|
while (right !== 0) {
|
||||||
|
const next = left % right
|
||||||
|
left = right
|
||||||
|
right = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
const gcd3 = (a: number, b: number, c: number) => gcd2(gcd2(a, b), c)
|
||||||
|
|
||||||
|
const createRange = (magnitude: number, allowNegative: boolean) => {
|
||||||
|
if (magnitude === 0) return [0]
|
||||||
|
return allowNegative ? [-magnitude, magnitude] : [magnitude]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPinchDistance = (points: PointerPoint[]) => {
|
||||||
|
if (points.length < 2) return 0
|
||||||
|
return Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y)
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorRamp = (weight: number) => {
|
||||||
|
const cold = new THREE.Color('#7dff9a')
|
||||||
|
const warm = new THREE.Color('#f2ff6b')
|
||||||
|
const hot = new THREE.Color('#ffd166')
|
||||||
|
|
||||||
|
if (weight < 0.5) return cold.lerp(warm, weight / 0.5)
|
||||||
|
return warm.lerp(hot, (weight - 0.5) / 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getComplexityMode = (value: unknown): ComplexityMode => {
|
||||||
|
if (value === 'surface' || value === 'single' || value === 'range') return value
|
||||||
|
return 'surface'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVectorRays = (
|
||||||
|
complexity: number,
|
||||||
|
radius: number,
|
||||||
|
allowNegative: boolean,
|
||||||
|
normalizeDistance: boolean,
|
||||||
|
mode: ComplexityMode
|
||||||
|
) => {
|
||||||
|
const rays = new Map<string, RayBucket>()
|
||||||
|
let totalVectors = 0
|
||||||
|
if (mode === 'surface') return { points: [], totalVectors, maxMultiplicity: 0 }
|
||||||
|
|
||||||
|
const startComplexity = mode === 'range' ? 1 : complexity
|
||||||
|
|
||||||
|
for (let c = startComplexity; c <= complexity; c += 1) {
|
||||||
|
for (let ax = 0; ax <= c; ax += 1) {
|
||||||
|
for (let ay = 0; ay <= c - ax; ay += 1) {
|
||||||
|
const az = c - ax - ay
|
||||||
|
|
||||||
|
for (const x of createRange(ax, allowNegative)) {
|
||||||
|
for (const y of createRange(ay, allowNegative)) {
|
||||||
|
for (const z of createRange(az, allowNegative)) {
|
||||||
|
const divisor = gcd3(x, y, z) || 1
|
||||||
|
const rayX = x / divisor
|
||||||
|
const rayY = y / divisor
|
||||||
|
const rayZ = z / divisor
|
||||||
|
const key = `${rayX},${rayY},${rayZ}`
|
||||||
|
const bucket = rays.get(key)
|
||||||
|
|
||||||
|
totalVectors += 1
|
||||||
|
if (bucket) {
|
||||||
|
bucket.count += 1
|
||||||
|
} else {
|
||||||
|
rays.set(key, { x: rayX, y: rayY, z: rayZ, count: 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = Array.from(rays.values()).map((ray) => {
|
||||||
|
const length = normalizeDistance ? Math.hypot(ray.x, ray.y, ray.z) : Math.abs(ray.x) + Math.abs(ray.y) + Math.abs(ray.z)
|
||||||
|
return {
|
||||||
|
x: (ray.x / length) * radius,
|
||||||
|
y: (ray.y / length) * radius,
|
||||||
|
z: (ray.z / length) * radius,
|
||||||
|
count: ray.count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const maxMultiplicity = points.reduce((max, point) => Math.max(max, point.count), 1)
|
||||||
|
|
||||||
|
return { points, totalVectors, maxMultiplicity }
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPointTexture = () => {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 96
|
||||||
|
canvas.height = 96
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (ctx) {
|
||||||
|
const gradient = ctx.createRadialGradient(48, 48, 0, 48, 48, 48)
|
||||||
|
gradient.addColorStop(0, 'rgba(255,255,255,1)')
|
||||||
|
gradient.addColorStop(0.24, 'rgba(255,255,255,0.92)')
|
||||||
|
gradient.addColorStop(0.58, 'rgba(255,255,255,0.42)')
|
||||||
|
gradient.addColorStop(1, 'rgba(255,255,255,0)')
|
||||||
|
ctx.fillStyle = gradient
|
||||||
|
ctx.fillRect(0, 0, 96, 96)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new THREE.CanvasTexture(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Component({
|
||||||
|
config,
|
||||||
|
onComplete,
|
||||||
|
onProgress,
|
||||||
|
theme,
|
||||||
|
className,
|
||||||
|
host
|
||||||
|
}: GlitchComponentProps) {
|
||||||
|
const params = config.params as ComponentParams
|
||||||
|
const initialAllowNegative = typeof params.allowNegative === 'boolean' ? params.allowNegative : true
|
||||||
|
const initialNormalizeDistance = typeof params.normalizeDistance === 'boolean' ? params.normalizeDistance : true
|
||||||
|
const initialRadius = clamp(numberParam(params.radius, 4), 1, 8)
|
||||||
|
const initialComplexity = Math.round(clamp(numberParam(params.complexity, 8), 1, MAX_COMPLEXITY))
|
||||||
|
const initialDotRadius = clamp(numberParam(params.dotRadius, 0.17), 0.05, MAX_DOT_RADIUS)
|
||||||
|
const initialComplexityMode = getComplexityMode(params.complexityMode)
|
||||||
|
|
||||||
|
const [allowNegative, setAllowNegative] = useState(initialAllowNegative)
|
||||||
|
const [normalizeDistance, setNormalizeDistance] = useState(initialNormalizeDistance)
|
||||||
|
const [radius, setRadius] = useState(initialRadius)
|
||||||
|
const [complexity, setComplexity] = useState(initialComplexity)
|
||||||
|
const [dotRadius, setDotRadius] = useState(initialDotRadius)
|
||||||
|
const [complexityMode, setComplexityMode] = useState<ComplexityMode>(initialComplexityMode)
|
||||||
|
const [completed, setCompleted] = useState(false)
|
||||||
|
const mountRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const groupRef = useRef<THREE.Group | null>(null)
|
||||||
|
const rendererRef = useRef<THREE.WebGLRenderer | null>(null)
|
||||||
|
const pointsRef = useRef<THREE.Points | null>(null)
|
||||||
|
const pointMaterialRef = useRef<THREE.PointsMaterial | null>(null)
|
||||||
|
const shellRef = useRef<THREE.Mesh | null>(null)
|
||||||
|
const l1ShellRef = useRef<THREE.Mesh | null>(null)
|
||||||
|
const dragRef = useRef<DragState | null>(null)
|
||||||
|
const pointersRef = useRef<Map<number, PointerPoint>>(new Map())
|
||||||
|
const pinchRef = useRef<PinchState | null>(null)
|
||||||
|
const cameraDistanceRef = useRef(DEFAULT_CAMERA_DISTANCE)
|
||||||
|
const currentCameraDistanceRef = useRef(DEFAULT_CAMERA_DISTANCE)
|
||||||
|
const idleRotationFactorRef = useRef(1)
|
||||||
|
const rotationRef = useRef({ x: -0.45, y: 0.58 })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAllowNegative(initialAllowNegative)
|
||||||
|
}, [initialAllowNegative])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNormalizeDistance(initialNormalizeDistance)
|
||||||
|
}, [initialNormalizeDistance])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRadius(initialRadius)
|
||||||
|
}, [initialRadius])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setComplexity(initialComplexity)
|
||||||
|
}, [initialComplexity])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDotRadius(initialDotRadius)
|
||||||
|
}, [initialDotRadius])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setComplexityMode(initialComplexityMode)
|
||||||
|
}, [initialComplexityMode])
|
||||||
|
|
||||||
|
const sphereData = useMemo(
|
||||||
|
() => getVectorRays(complexity, radius, allowNegative, normalizeDistance, complexityMode),
|
||||||
|
[allowNegative, complexity, complexityMode, normalizeDistance, radius]
|
||||||
|
)
|
||||||
|
const { points, totalVectors, maxMultiplicity } = sphereData
|
||||||
|
|
||||||
|
const progress = Math.round(clamp((complexity / MAX_COMPLEXITY) * 100, 0, 100))
|
||||||
|
const duplicateCount = totalVectors - points.length
|
||||||
|
const isSurfaceMode = complexityMode === 'surface'
|
||||||
|
const isRangeMode = complexityMode === 'range'
|
||||||
|
const statRays = isSurfaceMode ? '∞' : String(points.length)
|
||||||
|
const statVectors = isSurfaceMode ? '∞' : String(totalVectors)
|
||||||
|
const statOverlap = isSurfaceMode ? 'surface' : String(duplicateCount)
|
||||||
|
const statMaxStack = isSurfaceMode ? '--' : String(maxMultiplicity)
|
||||||
|
|
||||||
|
const themeStyle = useMemo(() => ({
|
||||||
|
'--gc-primary': theme?.primary,
|
||||||
|
'--gc-accent': theme?.accent,
|
||||||
|
'--gc-bg': theme?.bg,
|
||||||
|
'--gc-bg-secondary': theme?.bgSecondary,
|
||||||
|
'--gc-text': theme?.text,
|
||||||
|
'--gc-text-muted': theme?.textMuted,
|
||||||
|
'--gc-border': theme?.border,
|
||||||
|
'--sphere-radius': radius
|
||||||
|
}) as CSSProperties, [radius, theme])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onProgress?.(progress)
|
||||||
|
}, [onProgress, progress])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mount = mountRef.current
|
||||||
|
if (!mount) return undefined
|
||||||
|
|
||||||
|
const scene = new THREE.Scene()
|
||||||
|
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100)
|
||||||
|
camera.position.set(0, 0, currentCameraDistanceRef.current)
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||||
|
renderer.setClearColor(0x000000, 0)
|
||||||
|
mount.appendChild(renderer.domElement)
|
||||||
|
rendererRef.current = renderer
|
||||||
|
|
||||||
|
const group = new THREE.Group()
|
||||||
|
scene.add(group)
|
||||||
|
groupRef.current = group
|
||||||
|
|
||||||
|
const ambient = new THREE.AmbientLight(0xf1ffc4, 0.45)
|
||||||
|
scene.add(ambient)
|
||||||
|
const keyLight = new THREE.DirectionalLight(0xf2ff9b, 2.25)
|
||||||
|
keyLight.position.set(3.5, 4.5, 6)
|
||||||
|
scene.add(keyLight)
|
||||||
|
const fillLight = new THREE.DirectionalLight(0x79ff8c, 0.85)
|
||||||
|
fillLight.position.set(-5, -2, 3)
|
||||||
|
scene.add(fillLight)
|
||||||
|
const rimLight = new THREE.DirectionalLight(0xffffb2, 1.05)
|
||||||
|
rimLight.position.set(-2, 3, -5)
|
||||||
|
scene.add(rimLight)
|
||||||
|
|
||||||
|
const shellGeometry = new THREE.SphereGeometry(1, 96, 48)
|
||||||
|
const shellMaterial = new THREE.MeshLambertMaterial({
|
||||||
|
color: 0xcfff55,
|
||||||
|
emissive: 0x16260a,
|
||||||
|
emissiveIntensity: 0.18,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.055,
|
||||||
|
wireframe: true,
|
||||||
|
depthWrite: false
|
||||||
|
})
|
||||||
|
const shell = new THREE.Mesh(shellGeometry, shellMaterial)
|
||||||
|
group.add(shell)
|
||||||
|
shellRef.current = shell
|
||||||
|
|
||||||
|
const l1ShellGeometry = new THREE.OctahedronGeometry(1, 0)
|
||||||
|
const l1ShellMaterial = new THREE.MeshLambertMaterial({
|
||||||
|
color: 0xcfff55,
|
||||||
|
emissive: 0x16260a,
|
||||||
|
emissiveIntensity: 0.16,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.055,
|
||||||
|
wireframe: true,
|
||||||
|
depthWrite: false,
|
||||||
|
flatShading: true
|
||||||
|
})
|
||||||
|
const l1Shell = new THREE.Mesh(l1ShellGeometry, l1ShellMaterial)
|
||||||
|
l1Shell.visible = false
|
||||||
|
group.add(l1Shell)
|
||||||
|
l1ShellRef.current = l1Shell
|
||||||
|
|
||||||
|
const axisMaterial = new THREE.LineBasicMaterial({ color: 0xd7ff57, transparent: true, opacity: 0.12 })
|
||||||
|
const axisGeometry = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(-1, 0, 0),
|
||||||
|
new THREE.Vector3(1, 0, 0),
|
||||||
|
new THREE.Vector3(0, -1, 0),
|
||||||
|
new THREE.Vector3(0, 1, 0),
|
||||||
|
new THREE.Vector3(0, 0, -1),
|
||||||
|
new THREE.Vector3(0, 0, 1)
|
||||||
|
])
|
||||||
|
const axes = new THREE.LineSegments(axisGeometry, axisMaterial)
|
||||||
|
group.add(axes)
|
||||||
|
|
||||||
|
const pointMaterial = new THREE.PointsMaterial({
|
||||||
|
color: 0xffffff,
|
||||||
|
size: dotRadius,
|
||||||
|
map: createPointTexture(),
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.96,
|
||||||
|
depthWrite: false,
|
||||||
|
vertexColors: true,
|
||||||
|
blending: THREE.AdditiveBlending
|
||||||
|
})
|
||||||
|
pointMaterialRef.current = pointMaterial
|
||||||
|
|
||||||
|
const pointGeometry = new THREE.BufferGeometry()
|
||||||
|
const pointCloud = new THREE.Points(pointGeometry, pointMaterial)
|
||||||
|
group.add(pointCloud)
|
||||||
|
pointsRef.current = pointCloud
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const rect = mount.getBoundingClientRect()
|
||||||
|
const width = Math.max(1, rect.width)
|
||||||
|
const height = Math.max(1, rect.height)
|
||||||
|
camera.aspect = width / height
|
||||||
|
camera.updateProjectionMatrix()
|
||||||
|
renderer.setSize(width, height, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(resize)
|
||||||
|
resizeObserver.observe(mount)
|
||||||
|
resize()
|
||||||
|
|
||||||
|
let animationFrame = 0
|
||||||
|
const render = () => {
|
||||||
|
const rotation = rotationRef.current
|
||||||
|
currentCameraDistanceRef.current += (cameraDistanceRef.current - currentCameraDistanceRef.current) * 0.12
|
||||||
|
camera.position.z = currentCameraDistanceRef.current
|
||||||
|
camera.lookAt(0, 0, 0)
|
||||||
|
group.rotation.x += (rotation.x - group.rotation.x) * 0.08
|
||||||
|
group.rotation.y += (rotation.y - group.rotation.y) * 0.08
|
||||||
|
if (dragRef.current || pinchRef.current) {
|
||||||
|
idleRotationFactorRef.current = 0
|
||||||
|
} else {
|
||||||
|
idleRotationFactorRef.current += (1 - idleRotationFactorRef.current) * 0.006
|
||||||
|
group.rotation.y += IDLE_ROTATION_SPEED * idleRotationFactorRef.current
|
||||||
|
}
|
||||||
|
rotation.y = group.rotation.y
|
||||||
|
renderer.render(scene, camera)
|
||||||
|
animationFrame = window.requestAnimationFrame(render)
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(animationFrame)
|
||||||
|
resizeObserver.disconnect()
|
||||||
|
mount.removeChild(renderer.domElement)
|
||||||
|
pointGeometry.dispose()
|
||||||
|
pointMaterial.map?.dispose()
|
||||||
|
pointMaterial.dispose()
|
||||||
|
axisGeometry.dispose()
|
||||||
|
axisMaterial.dispose()
|
||||||
|
shellGeometry.dispose()
|
||||||
|
shellMaterial.dispose()
|
||||||
|
l1ShellGeometry.dispose()
|
||||||
|
l1ShellMaterial.dispose()
|
||||||
|
renderer.dispose()
|
||||||
|
rendererRef.current = null
|
||||||
|
groupRef.current = null
|
||||||
|
pointsRef.current = null
|
||||||
|
pointMaterialRef.current = null
|
||||||
|
shellRef.current = null
|
||||||
|
l1ShellRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pointCloud = pointsRef.current
|
||||||
|
if (!pointCloud) return
|
||||||
|
|
||||||
|
const positions = new Float32Array(points.length * 3)
|
||||||
|
const colors = new Float32Array(points.length * 3)
|
||||||
|
points.forEach((point, index) => {
|
||||||
|
positions[index * 3] = point.x
|
||||||
|
positions[index * 3 + 1] = point.y
|
||||||
|
positions[index * 3 + 2] = point.z
|
||||||
|
const weight = maxMultiplicity <= 1 ? 0 : (point.count - 1) / (maxMultiplicity - 1)
|
||||||
|
const color = colorRamp(weight)
|
||||||
|
colors[index * 3] = color.r
|
||||||
|
colors[index * 3 + 1] = color.g
|
||||||
|
colors[index * 3 + 2] = color.b
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextGeometry = new THREE.BufferGeometry()
|
||||||
|
nextGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
|
||||||
|
nextGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))
|
||||||
|
pointCloud.geometry.dispose()
|
||||||
|
pointCloud.geometry = nextGeometry
|
||||||
|
}, [maxMultiplicity, points])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
shellRef.current?.scale.setScalar(radius)
|
||||||
|
l1ShellRef.current?.scale.setScalar(radius)
|
||||||
|
const axes = groupRef.current?.children.find((child) => child.type === 'LineSegments')
|
||||||
|
axes?.scale.setScalar(radius * 1.18)
|
||||||
|
}, [radius])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pointsRef.current) {
|
||||||
|
pointsRef.current.visible = !isSurfaceMode
|
||||||
|
}
|
||||||
|
|
||||||
|
const euclideanShell = shellRef.current
|
||||||
|
const l1Shell = l1ShellRef.current
|
||||||
|
if (euclideanShell) {
|
||||||
|
euclideanShell.visible = normalizeDistance
|
||||||
|
}
|
||||||
|
if (l1Shell) {
|
||||||
|
l1Shell.visible = !normalizeDistance
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const shell of [euclideanShell, l1Shell]) {
|
||||||
|
const shellMaterial = shell?.material
|
||||||
|
if (shellMaterial instanceof THREE.MeshLambertMaterial) {
|
||||||
|
shellMaterial.wireframe = !isSurfaceMode
|
||||||
|
shellMaterial.opacity = isSurfaceMode ? 0.34 : 0.055
|
||||||
|
shellMaterial.depthWrite = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isSurfaceMode, normalizeDistance])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const accent = new THREE.Color(theme?.accent ?? '#4de1c1')
|
||||||
|
for (const shell of [shellRef.current, l1ShellRef.current]) {
|
||||||
|
const shellMaterial = shell?.material
|
||||||
|
if (shellMaterial instanceof THREE.MeshLambertMaterial) {
|
||||||
|
shellMaterial.color.copy(accent)
|
||||||
|
shellMaterial.emissive.set('#16260a')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [theme?.accent])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pointMaterialRef.current) {
|
||||||
|
pointMaterialRef.current.size = dotRadius
|
||||||
|
}
|
||||||
|
}, [dotRadius])
|
||||||
|
|
||||||
|
const playClick = useCallback((target: string) => {
|
||||||
|
host?.playSound?.('ui.button_click', { target, component: config.name })
|
||||||
|
}, [config.name, host])
|
||||||
|
|
||||||
|
const finish = useCallback(() => {
|
||||||
|
if (completed) return
|
||||||
|
|
||||||
|
setCompleted(true)
|
||||||
|
playClick('complete')
|
||||||
|
onComplete({
|
||||||
|
success: true,
|
||||||
|
score: 100,
|
||||||
|
data: {
|
||||||
|
allowNegative,
|
||||||
|
normalizeDistance,
|
||||||
|
complexityMode,
|
||||||
|
radius,
|
||||||
|
dotRadius,
|
||||||
|
complexity,
|
||||||
|
rays: points.length,
|
||||||
|
vectors: totalVectors,
|
||||||
|
duplicateRays: duplicateCount,
|
||||||
|
maxMultiplicity,
|
||||||
|
definition: isSurfaceMode
|
||||||
|
? normalizeDistance
|
||||||
|
? 'A sphere is a continuous surface: infinitely many points at Euclidean distance r from the centre.'
|
||||||
|
: 'The L1 surface is a continuous octahedral surface where |x| + |y| + |z| = r.'
|
||||||
|
: isRangeMode
|
||||||
|
? 'A Diophantine sphere places one point on each distinct integer vector ray at radius r, combining complexities 1 through C.'
|
||||||
|
: 'A Diophantine sphere places one point on each distinct integer vector ray at radius r for a single complexity C.',
|
||||||
|
configId: config.id,
|
||||||
|
completedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
allowNegative,
|
||||||
|
completed,
|
||||||
|
complexity,
|
||||||
|
config.id,
|
||||||
|
duplicateCount,
|
||||||
|
dotRadius,
|
||||||
|
isRangeMode,
|
||||||
|
isSurfaceMode,
|
||||||
|
maxMultiplicity,
|
||||||
|
normalizeDistance,
|
||||||
|
onComplete,
|
||||||
|
playClick,
|
||||||
|
points.length,
|
||||||
|
radius,
|
||||||
|
totalVectors,
|
||||||
|
complexityMode
|
||||||
|
])
|
||||||
|
|
||||||
|
const updateAllowNegative = useCallback(() => {
|
||||||
|
playClick('allow-negative')
|
||||||
|
setAllowNegative((value) => !value)
|
||||||
|
}, [playClick])
|
||||||
|
|
||||||
|
const updateNormalizeDistance = useCallback(() => {
|
||||||
|
playClick('normalize-distance')
|
||||||
|
setNormalizeDistance((value) => !value)
|
||||||
|
}, [playClick])
|
||||||
|
|
||||||
|
const updateRadius = useCallback((value: number) => {
|
||||||
|
playClick('radius')
|
||||||
|
setRadius(clamp(value, 1, 8))
|
||||||
|
}, [playClick])
|
||||||
|
|
||||||
|
const updateComplexity = useCallback((value: number) => {
|
||||||
|
playClick('complexity')
|
||||||
|
setComplexity(Math.round(clamp(value, 1, MAX_COMPLEXITY)))
|
||||||
|
}, [playClick])
|
||||||
|
|
||||||
|
const updateDotRadius = useCallback((value: number) => {
|
||||||
|
playClick('dot-radius')
|
||||||
|
setDotRadius(clamp(value, 0.05, MAX_DOT_RADIUS))
|
||||||
|
}, [playClick])
|
||||||
|
|
||||||
|
const updateComplexityMode = useCallback((nextMode: ComplexityMode) => {
|
||||||
|
playClick(`${nextMode}-mode`)
|
||||||
|
setComplexityMode(nextMode)
|
||||||
|
}, [playClick])
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
|
idleRotationFactorRef.current = 0
|
||||||
|
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||||
|
|
||||||
|
const pointers = Array.from(pointersRef.current.values())
|
||||||
|
if (pointers.length >= 2) {
|
||||||
|
dragRef.current = null
|
||||||
|
pinchRef.current = {
|
||||||
|
distance: getPinchDistance(pointers),
|
||||||
|
cameraDistance: cameraDistanceRef.current
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pinchRef.current = null
|
||||||
|
dragRef.current = {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
rotationX: rotationRef.current.x,
|
||||||
|
rotationY: rotationRef.current.y
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (pointersRef.current.has(event.pointerId)) {
|
||||||
|
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointers = Array.from(pointersRef.current.values())
|
||||||
|
if (pointers.length >= 2) {
|
||||||
|
const pinch = pinchRef.current
|
||||||
|
if (!pinch) return
|
||||||
|
|
||||||
|
const nextDistance = getPinchDistance(pointers)
|
||||||
|
if (nextDistance > 0 && pinch.distance > 0) {
|
||||||
|
cameraDistanceRef.current = clamp(
|
||||||
|
pinch.cameraDistance * (pinch.distance / nextDistance),
|
||||||
|
MIN_CAMERA_DISTANCE,
|
||||||
|
MAX_CAMERA_DISTANCE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const drag = dragRef.current
|
||||||
|
if (!drag) return
|
||||||
|
|
||||||
|
rotationRef.current = {
|
||||||
|
x: clamp(drag.rotationX + (event.clientY - drag.y) * 0.008, -1.45, 1.45),
|
||||||
|
y: drag.rotationY + (event.clientX - drag.x) * 0.008
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePointerEnd = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
pointersRef.current.delete(event.pointerId)
|
||||||
|
const pointers = Array.from(pointersRef.current.values())
|
||||||
|
|
||||||
|
if (pointers.length >= 2) {
|
||||||
|
pinchRef.current = {
|
||||||
|
distance: getPinchDistance(pointers),
|
||||||
|
cameraDistance: cameraDistanceRef.current
|
||||||
|
}
|
||||||
|
dragRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pinchRef.current = null
|
||||||
|
const remainingPointer = pointers[0]
|
||||||
|
dragRef.current = remainingPointer
|
||||||
|
? {
|
||||||
|
x: remainingPointer.x,
|
||||||
|
y: remainingPointer.y,
|
||||||
|
rotationX: rotationRef.current.x,
|
||||||
|
rotationY: rotationRef.current.y
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
idleRotationFactorRef.current = 0
|
||||||
|
cameraDistanceRef.current = clamp(
|
||||||
|
cameraDistanceRef.current + event.deltaY * 0.012,
|
||||||
|
MIN_CAMERA_DISTANCE,
|
||||||
|
MAX_CAMERA_DISTANCE
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const rootClassName = className ? `${styles.wrapper} ${className}` : styles.wrapper
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={rootClassName} style={themeStyle} aria-label="Diophantine Sphere visualizer">
|
||||||
|
<div className={styles.frame}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<span className={styles.kicker}>Integer rays</span>
|
||||||
|
<h2>Diophantine Sphere</h2>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={styles.metricStrip} aria-label="Sphere statistics">
|
||||||
|
<div>
|
||||||
|
<span>rays</span>
|
||||||
|
<strong>{statRays}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>vectors</span>
|
||||||
|
<strong>{statVectors}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>overlap</span>
|
||||||
|
<strong>{statOverlap}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>max stack</span>
|
||||||
|
<strong>{statMaxStack}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main className={styles.stageGrid}>
|
||||||
|
<section
|
||||||
|
className={styles.viewport}
|
||||||
|
aria-label="3D Diophantine Sphere"
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerEnd}
|
||||||
|
onPointerCancel={handlePointerEnd}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
>
|
||||||
|
<div ref={mountRef} className={styles.canvasHost} />
|
||||||
|
<div className={styles.axisLabels} aria-hidden="true">
|
||||||
|
<span>x</span>
|
||||||
|
<span>y</span>
|
||||||
|
<span>z</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className={styles.controlPanel}>
|
||||||
|
|
||||||
|
|
||||||
|
<div className={styles.modeControl} aria-label="Complexity mode">
|
||||||
|
<button
|
||||||
|
className={complexityMode === 'surface' ? styles.modeActive : ''}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateComplexityMode('surface')}
|
||||||
|
>
|
||||||
|
Surface
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={complexityMode === 'single' ? styles.modeActive : ''}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateComplexityMode('single')}
|
||||||
|
>
|
||||||
|
Single C
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={complexityMode === 'range' ? styles.modeActive : ''}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateComplexityMode('range')}
|
||||||
|
>
|
||||||
|
Range 1..C
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className={styles.toggleRow}>
|
||||||
|
<span>
|
||||||
|
<strong>Allow negative values</strong>
|
||||||
|
<small>{allowNegative ? 'All octants' : 'Positive octant'}</small>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" checked={allowNegative} onChange={updateAllowNegative} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={styles.toggleRow}>
|
||||||
|
<span>
|
||||||
|
<strong>Normalize distance</strong>
|
||||||
|
<small>{normalizeDistance ? 'Euclidean r' : 'Complexity r'}</small>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" checked={normalizeDistance} onChange={updateNormalizeDistance} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={styles.sliderRow}>
|
||||||
|
<span>
|
||||||
|
<strong>D</strong>
|
||||||
|
<small>{radius.toFixed(2)}</small>
|
||||||
|
</span>
|
||||||
|
<div className={styles.sliderControl}>
|
||||||
|
<button type="button" onClick={() => updateRadius(radius - 0.25)} aria-label="Decrease radius">
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={8}
|
||||||
|
step={0.25}
|
||||||
|
value={radius}
|
||||||
|
onInput={(event) => updateRadius(Number(event.currentTarget.value))}
|
||||||
|
onChange={(event) => updateRadius(Number(event.currentTarget.value))}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => updateRadius(radius + 0.25)} aria-label="Increase radius">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={styles.sliderRow}>
|
||||||
|
<span>
|
||||||
|
<strong>C</strong>
|
||||||
|
<small>{complexity}</small>
|
||||||
|
</span>
|
||||||
|
<div className={styles.sliderControl}>
|
||||||
|
<button type="button" onClick={() => updateComplexity(complexity - 1)} aria-label="Decrease complexity">
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={MAX_COMPLEXITY}
|
||||||
|
step={1}
|
||||||
|
value={complexity}
|
||||||
|
onInput={(event) => updateComplexity(Number(event.currentTarget.value))}
|
||||||
|
onChange={(event) => updateComplexity(Number(event.currentTarget.value))}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => updateComplexity(complexity + 1)} aria-label="Increase complexity">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={styles.sliderRow}>
|
||||||
|
<span>
|
||||||
|
<strong>R</strong>
|
||||||
|
<small>{dotRadius.toFixed(2)}</small>
|
||||||
|
</span>
|
||||||
|
<div className={styles.sliderControl}>
|
||||||
|
<button type="button" onClick={() => updateDotRadius(dotRadius - 0.02)} aria-label="Decrease dot radius">
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0.05}
|
||||||
|
max={MAX_DOT_RADIUS}
|
||||||
|
step={0.01}
|
||||||
|
value={dotRadius}
|
||||||
|
onInput={(event) => updateDotRadius(Number(event.currentTarget.value))}
|
||||||
|
onChange={(event) => updateDotRadius(Number(event.currentTarget.value))}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => updateDotRadius(dotRadius + 0.02)} aria-label="Increase dot radius">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={styles.rayNote}>
|
||||||
|
<span className={styles.kicker}>colour rule</span>
|
||||||
|
<p>
|
||||||
|
{isSurfaceMode
|
||||||
|
? normalizeDistance
|
||||||
|
? 'Start with the continuous round surface: every Euclidean direction from the centre is present.'
|
||||||
|
: 'Unchecked distance normalization shows the continuous L1 surface that the integer grid lands on.'
|
||||||
|
: isRangeMode
|
||||||
|
? 'Cool points are single rays; gold and rose points mark directions hit by more vectors in the range.'
|
||||||
|
: 'Single-complexity mode exposes the triangular grid before lower complexities fill the gaps.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className={styles.footer}>
|
||||||
|
<span>
|
||||||
|
{isSurfaceMode
|
||||||
|
? normalizeDistance
|
||||||
|
? `Continuous sphere: infinitely many points at Euclidean radius ${radius.toFixed(2)}`
|
||||||
|
: `Continuous L1 surface: |x| + |y| + |z| = ${radius.toFixed(2)}`
|
||||||
|
: isRangeMode
|
||||||
|
? `Complexities 1-${complexity} collapse ${duplicateCount} overlapping vectors at ${normalizeDistance ? 'Euclidean' : 'complexity'} radius ${radius.toFixed(2)}`
|
||||||
|
: `Complexity ${complexity} places ${points.length} distinct rays at ${normalizeDistance ? 'Euclidean' : 'complexity'} radius ${radius.toFixed(2)}`}
|
||||||
|
</span>
|
||||||
|
<button className={styles.completeButton} type="button" onClick={finish} disabled={completed}>
|
||||||
|
{completed ? 'OK' : '✓'}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Iceland&family=JetBrains+Mono:wght@400;600&family=Russo+One&display=swap");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-bg: #0a0a0f;
|
||||||
|
--color-bg-secondary: #12121a;
|
||||||
|
--color-text: #e8e8ec;
|
||||||
|
--color-text-muted: #9999a8;
|
||||||
|
--color-primary: #6366f1;
|
||||||
|
--color-primary-hover: #818cf8;
|
||||||
|
--color-accent: #22d3ee;
|
||||||
|
--color-accent-secondary: #a855f7;
|
||||||
|
--color-border: #2a2a3a;
|
||||||
|
--font-main: "Iceland", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
--font-display: "Russo One", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", "Iceland", monospace;
|
||||||
|
--small-font: 1.875rem;
|
||||||
|
--spacing-xs: 0.5rem;
|
||||||
|
--spacing-sm: 1rem;
|
||||||
|
--spacing-md: 2rem;
|
||||||
|
--spacing-lg: 4rem;
|
||||||
|
--spacing-xl: 6rem;
|
||||||
|
--max-width: 1200px;
|
||||||
|
--border-radius: 12px;
|
||||||
|
--border-radius-sm: 8px;
|
||||||
|
--transition: 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgb(99 102 241 / 18%), transparent 36%),
|
||||||
|
linear-gradient(180deg, #080810, #0d1018);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { Leva, useControls } from 'leva'
|
||||||
|
import Component from './Component'
|
||||||
|
import { metadata } from './index'
|
||||||
|
import './dev-theme.css'
|
||||||
|
|
||||||
|
function DevHarness() {
|
||||||
|
const params = useControls({
|
||||||
|
allowNegative: { value: true },
|
||||||
|
normalizeDistance: { value: true },
|
||||||
|
complexityMode: {
|
||||||
|
value: 'surface',
|
||||||
|
options: {
|
||||||
|
Surface: 'surface',
|
||||||
|
'Range 1..C': 'range',
|
||||||
|
'Single C': 'single'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
radius: { value: 4, min: 1, max: 8, step: 0.25 },
|
||||||
|
complexity: { value: 8, min: 1, max: 96, step: 1 },
|
||||||
|
dotRadius: { value: 0.17, min: 0.05, max: 1.2, step: 0.01 }
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh' }}>
|
||||||
|
<Leva hidden />
|
||||||
|
<Component
|
||||||
|
config={{
|
||||||
|
id: 'dev',
|
||||||
|
name: metadata.name,
|
||||||
|
version: metadata.version,
|
||||||
|
params: params as Record<string, unknown>
|
||||||
|
}}
|
||||||
|
theme={{
|
||||||
|
primary: '#d7ff57',
|
||||||
|
accent: '#cfff55',
|
||||||
|
bg: '#050806',
|
||||||
|
bgSecondary: '#10150b',
|
||||||
|
text: '#edf8d4',
|
||||||
|
textMuted: '#9fb47d',
|
||||||
|
border: '#31411f'
|
||||||
|
}}
|
||||||
|
onProgress={(percent) => {
|
||||||
|
console.log('Progress:', percent)
|
||||||
|
}}
|
||||||
|
onComplete={(result) => {
|
||||||
|
console.log('Complete:', result)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<DevHarness />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import Component from './Component'
|
||||||
|
import type { GlitchComponentMetadata } from './types'
|
||||||
|
|
||||||
|
export default Component
|
||||||
|
|
||||||
|
export const metadata: GlitchComponentMetadata = {
|
||||||
|
name: 'diophantine-sphere',
|
||||||
|
displayName: 'Diophantine Sphere',
|
||||||
|
version: '1.0.0',
|
||||||
|
paramSchema: {
|
||||||
|
allowNegative: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Allow Negative Values',
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
normalizeDistance: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Normalize Distance',
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
complexityMode: {
|
||||||
|
type: 'select',
|
||||||
|
label: 'Complexity Mode',
|
||||||
|
default: 'surface',
|
||||||
|
options: [
|
||||||
|
{ value: 'surface', label: 'Surface' },
|
||||||
|
{ value: 'single', label: 'Single C' },
|
||||||
|
{ value: 'range', label: 'Range 1..C' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
radius: {
|
||||||
|
type: 'range',
|
||||||
|
label: 'D',
|
||||||
|
default: 4,
|
||||||
|
min: 1,
|
||||||
|
max: 8,
|
||||||
|
step: 0.25
|
||||||
|
},
|
||||||
|
complexity: {
|
||||||
|
type: 'range',
|
||||||
|
label: 'C',
|
||||||
|
default: 8,
|
||||||
|
min: 1,
|
||||||
|
max: 96,
|
||||||
|
step: 1
|
||||||
|
},
|
||||||
|
dotRadius: {
|
||||||
|
type: 'range',
|
||||||
|
label: 'R',
|
||||||
|
default: 0.17,
|
||||||
|
min: 0.05,
|
||||||
|
max: 1.2,
|
||||||
|
step: 0.01
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultParams: {
|
||||||
|
allowNegative: true,
|
||||||
|
normalizeDistance: true,
|
||||||
|
complexityMode: 'surface',
|
||||||
|
radius: 4,
|
||||||
|
complexity: 8,
|
||||||
|
dotRadius: 0.17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
GlitchComponentProps,
|
||||||
|
GlitchComponentConfig,
|
||||||
|
GlitchComponentResult,
|
||||||
|
GlitchTheme,
|
||||||
|
GlitchHostBridge
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
type GCRegistry = Record<string, { default: typeof Component; metadata: GlitchComponentMetadata }>
|
||||||
|
;(window as unknown as { GlitchComponents: GCRegistry }).GlitchComponents ??= {}
|
||||||
|
;(window as unknown as { GlitchComponents: GCRegistry }).GlitchComponents[metadata.name] = { default: Component, metadata }
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
.wrapper {
|
||||||
|
--gc-bg: var(--color-bg, #050806);
|
||||||
|
--gc-bg-secondary: var(--color-bg-secondary, #10150b);
|
||||||
|
--gc-text: var(--color-text, #edf8d4);
|
||||||
|
--gc-text-muted: var(--color-text-muted, #9fb47d);
|
||||||
|
--gc-primary: var(--color-primary, #d7ff57);
|
||||||
|
--gc-accent: var(--color-accent, #cfff55);
|
||||||
|
--gc-border: var(--color-border, #31411f);
|
||||||
|
--gc-font-main: var(--font-main, system-ui, sans-serif);
|
||||||
|
--gc-font-display: var(--font-display, var(--gc-font-main));
|
||||||
|
--gc-font-mono: var(--font-mono, monospace);
|
||||||
|
--gc-shell-padding: clamp(8px, 2vw, 18px);
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--gc-shell-padding);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper *,
|
||||||
|
.wrapper *::before,
|
||||||
|
.wrapper *::after {
|
||||||
|
box-sizing: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame {
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, calc((100dvh - (var(--gc-shell-padding) * 2)) * 1.9));
|
||||||
|
max-height: calc(100dvh - (var(--gc-shell-padding) * 2));
|
||||||
|
aspect-ratio: 1.9 / 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 1em;
|
||||||
|
color: var(--gc-text);
|
||||||
|
font-family: var(--gc-font-main);
|
||||||
|
font-size: clamp(11px, 1.16cqw, 20px);
|
||||||
|
line-height: 1.2;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, color-mix(in srgb, var(--gc-bg) 90%, #2a310b), var(--gc-bg-secondary)),
|
||||||
|
var(--gc-bg);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--gc-border) 74%, transparent);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgb(215 255 87 / 5%),
|
||||||
|
0 18px 48px rgb(0 0 0 / 34%);
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header,
|
||||||
|
.metricStrip,
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header,
|
||||||
|
.footer {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
left: 1em;
|
||||||
|
top: 0.9em;
|
||||||
|
width: min(25em, 34%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h2 {
|
||||||
|
margin: 0.1em 0 0;
|
||||||
|
font-family: var(--gc-font-display);
|
||||||
|
font-size: 1.64em;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
display: none;
|
||||||
|
color: color-mix(in srgb, var(--gc-accent) 78%, white);
|
||||||
|
font-family: var(--gc-font-mono);
|
||||||
|
font-size: 0.62em;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricStrip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
left: 1em;
|
||||||
|
bottom: 1em;
|
||||||
|
width: min(25em, 34%);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricStrip div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.55em 0.68em;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--gc-border) 78%, transparent);
|
||||||
|
background: rgb(4 9 13 / 58%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricStrip span,
|
||||||
|
.footer span {
|
||||||
|
color: var(--gc-text-muted);
|
||||||
|
font-family: var(--gc-font-mono);
|
||||||
|
font-size: 0.62em;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricStrip strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.12em;
|
||||||
|
color: var(--gc-accent);
|
||||||
|
font-family: var(--gc-font-display);
|
||||||
|
font-size: 1.3em;
|
||||||
|
line-height: 0.96;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stageGrid {
|
||||||
|
min-height: 0;
|
||||||
|
grid-row: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(16em, 0.48fr) minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport,
|
||||||
|
.controlPanel,
|
||||||
|
.footer {
|
||||||
|
|
||||||
|
background: rgb(4 9 13 / 56%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
position: relative;
|
||||||
|
grid-column: 2;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
width: min(100%, calc(100dvh - 5.2em));
|
||||||
|
aspect-ratio: 1;
|
||||||
|
justify-self: end;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHost {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHost canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport::before {
|
||||||
|
content: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 1.1em;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--gc-accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.axisLabels {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0.8em;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.38em;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axisLabels span {
|
||||||
|
width: 1.8em;
|
||||||
|
height: 1.8em;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--gc-border) 80%, transparent);
|
||||||
|
background: rgb(5 10 14 / 76%);
|
||||||
|
color: color-mix(in srgb, var(--gc-text-muted) 82%, white);
|
||||||
|
font-family: var(--gc-font-mono);
|
||||||
|
font-size: 0.68em;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlPanel {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 0.55em;
|
||||||
|
padding: 0.7em;
|
||||||
|
margin-top: 4.2em;
|
||||||
|
margin-bottom: 6.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equation,
|
||||||
|
.rayNote {
|
||||||
|
padding-bottom: 0.55em;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--gc-border) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.equation p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--gc-text);
|
||||||
|
font-family: var(--gc-font-display);
|
||||||
|
font-size: 1.25em;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rayNote p {
|
||||||
|
margin: 0.42em 0 0;
|
||||||
|
color: color-mix(in srgb, var(--gc-text-muted) 86%, white);
|
||||||
|
font-size: 0.78em;
|
||||||
|
line-height: 1.24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rayNote,
|
||||||
|
.footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleRow,
|
||||||
|
.sliderRow {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4em;
|
||||||
|
padding: 0.58em;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--gc-border) 72%, transparent);
|
||||||
|
background: rgb(5 10 14 / 54%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modeControl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.28em;
|
||||||
|
padding: 0.28em;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--gc-border) 72%, transparent);
|
||||||
|
background: rgb(5 10 14 / 54%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modeControl button {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 2.15em;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--gc-text-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-family: var(--gc-font-mono);
|
||||||
|
font-size: 0.6em;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modeControl button:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--gc-accent) 38%, var(--gc-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.modeControl .modeActive {
|
||||||
|
border-color: color-mix(in srgb, var(--gc-accent) 70%, white);
|
||||||
|
background: color-mix(in srgb, var(--gc-accent) 18%, rgb(5 10 14));
|
||||||
|
color: color-mix(in srgb, var(--gc-accent) 86%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleRow {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleRow span,
|
||||||
|
.sliderRow span {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.55em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleRow strong,
|
||||||
|
.sliderRow strong {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--gc-text);
|
||||||
|
font-family: var(--gc-font-mono);
|
||||||
|
font-size: 0.68em;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleRow small,
|
||||||
|
.sliderRow small {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--gc-accent);
|
||||||
|
font-family: var(--gc-font-mono);
|
||||||
|
font-size: 0.68em;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleRow input[type="checkbox"] {
|
||||||
|
width: 2.6em;
|
||||||
|
height: 1.45em;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--gc-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliderControl {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliderControl button {
|
||||||
|
width: 1.9em;
|
||||||
|
height: 1.9em;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--gc-border) 78%, transparent);
|
||||||
|
background: rgb(5 10 14 / 74%);
|
||||||
|
color: var(--gc-text);
|
||||||
|
font: inherit;
|
||||||
|
font-family: var(--gc-font-mono);
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 900;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliderControl button:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--gc-accent) 62%, var(--gc-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliderControl input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
accent-color: var(--gc-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completeButton {
|
||||||
|
min-width: 3em;
|
||||||
|
min-height: 2.35em;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--gc-border) 78%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--gc-primary) 24%, rgb(5 9 18));
|
||||||
|
color: var(--gc-text);
|
||||||
|
font: inherit;
|
||||||
|
font-family: var(--gc-font-mono);
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completeButton:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: color-mix(in srgb, var(--gc-accent) 62%, var(--gc-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.completeButton:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.68;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: portrait) {
|
||||||
|
.frame {
|
||||||
|
width: min(100%, calc((100dvh - (var(--gc-shell-padding) * 2)) / 1.7));
|
||||||
|
aspect-ratio: 1 / 1.7;
|
||||||
|
font-size: clamp(10px, 3.15cqw, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricStrip {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricStrip div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stageGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
width: min(100%, 54dvh);
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlPanel {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
padding: 0.72em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equation,
|
||||||
|
.rayNote {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleRow,
|
||||||
|
.sliderRow {
|
||||||
|
padding: 0.58em;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
export interface GlitchComponentConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
params: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlitchHostBridge {
|
||||||
|
playSound?: (id: string, payload?: Record<string, unknown>) => void
|
||||||
|
stopSound?: (id: string, payload?: Record<string, unknown>) => void
|
||||||
|
emit?: (type: string, payload?: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlitchTheme {
|
||||||
|
primary: string
|
||||||
|
accent: string
|
||||||
|
bg: string
|
||||||
|
bgSecondary: string
|
||||||
|
text: string
|
||||||
|
textMuted: string
|
||||||
|
border: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlitchComponentResult {
|
||||||
|
success: boolean
|
||||||
|
score?: number
|
||||||
|
data?: unknown
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlitchComponentProps {
|
||||||
|
config: GlitchComponentConfig
|
||||||
|
onComplete: (result: GlitchComponentResult) => void
|
||||||
|
onProgress?: (percent: number) => void
|
||||||
|
theme?: GlitchTheme
|
||||||
|
className?: string
|
||||||
|
host?: GlitchHostBridge
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParamSchema {
|
||||||
|
[key: string]: {
|
||||||
|
type: 'number' | 'string' | 'boolean' | 'color' | 'select' | 'range'
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
default: unknown
|
||||||
|
options?: { value: string | number; label: string }[]
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlitchComponentMetadata {
|
||||||
|
name: string
|
||||||
|
displayName: string
|
||||||
|
version: string
|
||||||
|
paramSchema: ParamSchema
|
||||||
|
defaultParams: Record<string, unknown>
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": [
|
||||||
|
"vite/client"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => ({
|
||||||
|
plugins: [react(), cssInjectedByJsPlugin()],
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, 'src/index.tsx'),
|
||||||
|
name: 'GlitchComponent',
|
||||||
|
fileName: 'diophantine-sphere',
|
||||||
|
formats: ['iife']
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['react', 'react-dom'],
|
||||||
|
output: {
|
||||||
|
globals: {
|
||||||
|
react: 'React',
|
||||||
|
'react-dom': 'ReactDOM'
|
||||||
|
},
|
||||||
|
assetFileNames: 'assets/[name][extname]'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sourcemap: true,
|
||||||
|
minify: mode === 'production'
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3002,
|
||||||
|
open: true
|
||||||
|
}
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user