From 8b36467c09dc76b130b6a2dc4983dce8bf90c98a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 19 May 2026 13:35:39 +0100 Subject: [PATCH] Make Huginn payload cloud and stardust viewport native --- README.md | 16 +- inspector/src/HuginnFieldCanvas.tsx | 318 ++++++++++++++++++++-------- inspector/src/main.tsx | 276 +++++++++--------------- inspector/src/styles.css | 74 +------ package-lock.json | 4 +- package.json | 2 +- 6 files changed, 344 insertions(+), 346 deletions(-) diff --git a/README.md b/README.md index ceded32..344ac83 100644 --- a/README.md +++ b/README.md @@ -63,15 +63,19 @@ generated Huginn executable must use that package version in its filename. Huginn is read-only. Drop a `.cc`, `.msgpack`, or `.mpack` file onto the window to inspect the snapshot header, schema catalog, records, decoded MessagePack -payload previews, and an EpiphanyGraph view of the file's structured data tree +payload previews, and an EpiphanyGraph cloud of the file's structured payload data without registering application schemas. Huginn's background field uses the graph node AABB as its single world-space -authority. The WebGPU particle pass maps each particle pair to a deterministic -hashed quadtree cell, then lets the pair trade opacity with sine/cosine phase -while tracing a short segment through the packed flow texture. Zooming raises -the highest live quadtree level, so new detail appears from stable spatial -hashes instead of by accelerating the field. +authority. The WebGPU particle pass keeps a million particle slots available, +intersects the current screen bounds with the artwork bounds, and assigns only +visible quadtree cells to those slots. Each visible cell is seeded with the +Aetheria Stardust `RandomFirst` hash and xorshift follow-up sequence, then each +particle pair trades opacity with sine/cosine phase while tracing a short +segment through the packed flow texture. Zooming raises the live quadtree level; +coarse cells fade down while finer cells seeded from the same spatial lattice +fade in around them, so detail density rises without changing the field's +identity. ## Example diff --git a/inspector/src/HuginnFieldCanvas.tsx b/inspector/src/HuginnFieldCanvas.tsx index d323c02..d1fa62b 100644 --- a/inspector/src/HuginnFieldCanvas.tsx +++ b/inspector/src/HuginnFieldCanvas.tsx @@ -41,9 +41,12 @@ const FIELD_SIZE = 256; const MAX_DEPTH = 6; const MIN_BRUSH_SIZE = 4; const ENERGY_SPLIT_THRESHOLD = 0.038; -const PARTICLE_COUNT = new URLSearchParams(globalThis.location?.search ?? "").has("smoke") ? 8_192 : 65_536; +const PARTICLE_COUNT = new URLSearchParams(globalThis.location?.search ?? "").has("smoke") ? 8_192 : 1_048_576; const PARTICLE_STRIDE_FLOATS = 12; const MAX_NODE_ENVELOPES = 96; +const STARDUST_MAX_LEVEL = 13; +const STARDUST_COARSE_PARTICLES_PER_CELL = 2; +const STARDUST_FINE_PARTICLES_PER_CELL = 6; type NodeEnvelope = { id: string; @@ -97,6 +100,17 @@ struct SimUniforms { pad1: f32, }; +struct VisibleBand { + level: u32, + minX: u32, + minY: u32, + cellsX: u32, + cellsY: u32, + pairCount: u32, + weight: f32, + particlesPerCell: u32, +}; + @group(0) @binding(0) var particles: array; @group(0) @binding(1) var uniforms: SimUniforms; @group(0) @binding(2) var fieldSampler: sampler; @@ -104,67 +118,85 @@ struct SimUniforms { @group(0) @binding(4) var nodeEnvelopes: array; @group(0) @binding(5) var albedoTexture: texture_2d; -fn hash(value: f32) -> f32 { - return fract(sin(value * 12.9898) * 43758.5453); +fn hash32(value: u32) -> u32 { + var x = value; + x = (x ^ 61u) ^ (x >> 16u); + x = x * 9u; + x = x ^ (x >> 4u); + x = x * 0x27d4eb2du; + x = x ^ (x >> 15u); + return x; } -fn quadtreeCellsBeforeLevel(level: u32) -> u32 { - var total = 0u; - var cells = 1u; - for (var current = 0u; current < 8u; current = current + 1u) { - if (current >= level) { - break; - } - total = total + cells; - cells = cells * 4u; +fn hashPair(a: u32, b: u32) -> u32 { + var x = hash32(a); + var y = b; + y = y - (y << 6u); + y = y ^ (x >> 17u); + y = y - (y << 9u); + y = y ^ (x << 4u); + y = y - (y << 3u); + y = y ^ (y << 10u); + y = y ^ (y >> 15u); + return x ^ y; +} + +fn randomFirst(a: u32, b: u32) -> f32 { + return f32(hashPair(a, b) & 0x00ffffffu) / 16777216.0; +} + +fn xorshift(value: u32) -> u32 { + var state = value; + state = state ^ (state << 13u); + state = state ^ (state >> 17u); + state = state ^ (state << 5u); + return state; +} + +fn randomValue(state: u32) -> f32 { + return f32(state & 0x00ffffffu) / 16777216.0; +} + +fn fadeCurve(t: f32) -> f32 { + return t * t * t * (t * (6.0 * t - 15.0) + 10.0); +} + +fn grad(seed: u32, p: f32) -> f32 { + if ((hash32(seed) & 1u) == 0u) { + return p; } - return total; + return -p; } -fn maxQuadtreeLevelForPairs(pairCount: u32) -> u32 { - var level = 0u; - var total = 1u; - var cells = 1u; - for (var next = 1u; next < 8u; next = next + 1u) { - cells = cells * 4u; - if (total + cells > pairCount) { - break; - } - total = total + cells; - level = next; - } - return level; +fn perlinNoise1D(p: f32, seed: u32) -> f32 { + let pi = floor(p); + let pf = p - pi; + let cell = u32(max(pi, 0.0)); + let w = fadeCurve(pf); + return mix(grad(cell ^ seed, pf), grad((cell + 1u) ^ seed, pf - 1.0), w) * 2.0; } -fn quadtreeLevelForPair(pairIndex: u32, maxLevel: u32) -> u32 { - var level = 0u; - var first = 0u; - var cells = 1u; - for (var current = 0u; current < 8u; current = current + 1u) { - if (current > maxLevel) { - break; - } - if (pairIndex < first + cells) { - level = current; - break; - } - first = first + cells; - cells = cells * 4u; - } - return level; +fn tri(value: f32) -> f32 { + return abs(fract(value) - 0.5); } -fn wrap2(value: vec2f, size: vec2f) -> vec2f { - return fract(value / size) * size; +fn tri3(point: vec3f) -> vec3f { + return vec3f( + tri(point.z + tri(point.y)), + tri(point.z + tri(point.x)), + tri(point.y + tri(point.x)) + ); } -fn proceduralCurl(point: vec2f, seed: f32) -> vec2f { - let angle = - sin(point.x * 0.011 + uniforms.time * 0.23 + seed * 6.28318) + - cos(point.y * 0.017 - uniforms.time * 0.19 + seed * 4.71) + - sin((point.x + point.y) * 0.006 + uniforms.time * 0.11) + - sin(point.x * 0.041 + point.y * -0.032 + uniforms.time * 0.37 + seed); - return vec2f(cos(angle * 3.14159), sin(angle * 3.14159)); +fn triFlow(point: vec2f, seed: f32) -> vec2f { + let p1 = normalize(tri3(vec3f(point.x, seed, point.y)) * 2.0 - vec3f(1.0)); + let p2 = normalize(tri3(vec3f(point.x, seed, point.y) * 1.6180339) * 2.0 - vec3f(1.0)); + let c = cross(p1, p2); + return normalize(c.xz + vec2f(0.0001, 0.0)); +} + +fn parabola(x: f32, k: f32) -> f32 { + return pow(max(0.0, 4.0 * x * (1.0 - x)), k); } fn samplePacked(uv: vec2f) -> vec4f { @@ -187,6 +219,36 @@ fn channelGradient(uv: vec2f, channel: u32) -> vec2f { return vec2f(right.a - left.a, down.a - up.a); } +fn visibleUvBounds() -> vec4f { + let viewScale = max(uniforms.viewScale, 0.0001); + let worldMin = (vec2f(0.0, 0.0) - vec2f(uniforms.viewX, uniforms.viewY)) / viewScale; + let worldMax = (vec2f(uniforms.width, uniforms.height) - vec2f(uniforms.viewX, uniforms.viewY)) / viewScale; + let uvMin = clamp((worldMin - vec2f(uniforms.worldX, uniforms.worldY)) / vec2f(uniforms.worldWidth, uniforms.worldHeight), vec2f(0.0), vec2f(1.0)); + let uvMax = clamp((worldMax - vec2f(uniforms.worldX, uniforms.worldY)) / vec2f(uniforms.worldWidth, uniforms.worldHeight), vec2f(0.0), vec2f(1.0)); + return vec4f(min(uvMin, uvMax), max(uvMin, uvMax)); +} + +fn stardustLevelValue() -> f32 { + let screenSide = max(uniforms.worldWidth, uniforms.worldHeight) * max(uniforms.viewScale, 0.0001); + return clamp(log2(max(screenSide / 7.0, 1.0)) + uniforms.detail * 0.72, 4.0, f32(${STARDUST_MAX_LEVEL})); +} + +fn visibleBand(level: u32, particlesPerCell: u32, weight: f32) -> VisibleBand { + let span = 1u << level; + let uv = visibleUvBounds(); + let margin = 1.0 / f32(span); + let minUv = clamp(uv.xy - vec2f(margin), vec2f(0.0), vec2f(1.0)); + let maxUv = clamp(uv.zw + vec2f(margin), vec2f(0.0), vec2f(1.0)); + let minX = min(u32(floor(minUv.x * f32(span))), span - 1u); + let minY = min(u32(floor(minUv.y * f32(span))), span - 1u); + let maxX = min(u32(ceil(maxUv.x * f32(span))), span); + let maxY = min(u32(ceil(maxUv.y * f32(span))), span); + let cellsX = max(1u, maxX - minX); + let cellsY = max(1u, maxY - minY); + let visibleCells = cellsX * cellsY; + return VisibleBand(level, minX, minY, cellsX, cellsY, visibleCells * particlesPerCell, weight, particlesPerCell); +} + @compute @workgroup_size(128) fn updateParticles(@builtin(global_invocation_id) id: vec3u) { let index = id.x; @@ -198,10 +260,15 @@ fn updateParticles(@builtin(global_invocation_id) id: vec3u) { let pairIndex = index / 2u; let companion = index - pairIndex * 2u; let pairCount = max(1u, u32(uniforms.particleCount * 0.5)); - let pairCapacityLevel = maxQuadtreeLevelForPairs(pairCount); - let zoomLevel = u32(clamp(floor(log2(max(uniforms.viewScale, 0.25)) * 0.9 + uniforms.detail * 0.75 + 4.25), 2.0, 7.0)); - let activeLevel = min(pairCapacityLevel, zoomLevel); - let activePairs = min(pairCount, quadtreeCellsBeforeLevel(activeLevel + 1u)); + let levelValue = stardustLevelValue(); + let coarseLevel = u32(floor(levelValue)); + let fineLevel = min(coarseLevel + 1u, ${STARDUST_MAX_LEVEL}u); + let fineBlend = smoothstep(0.18, 0.86, fract(levelValue)); + let coarseBand = visibleBand(coarseLevel, ${STARDUST_COARSE_PARTICLES_PER_CELL}u, 1.0 - fineBlend * 0.72); + let fineBand = visibleBand(fineLevel, ${STARDUST_FINE_PARTICLES_PER_CELL}u, fineBlend); + let coarsePairs = min(coarseBand.pairCount, max(1u, pairCount / 3u)); + let finePairs = min(fineBand.pairCount, pairCount - coarsePairs); + let activePairs = coarsePairs + finePairs; if (pairIndex >= activePairs) { particle.color = vec4f(0.0); particle.size = 0.0; @@ -209,29 +276,52 @@ fn updateParticles(@builtin(global_invocation_id) id: vec3u) { return; } - let level = quadtreeLevelForPair(pairIndex, activeLevel); - let levelFirstPair = quadtreeCellsBeforeLevel(level); - let localPair = pairIndex - levelFirstPair; + var band = coarseBand; + var localPair = pairIndex; + if (pairIndex >= coarsePairs) { + band = fineBand; + localPair = pairIndex - coarsePairs; + } + + let level = band.level; let span = 1u << level; - let cell = vec2f(f32(localPair % span), f32(localPair / span)); - let cellHashSeed = f32(level) * 4096.0 + cell.x * 131.0 + cell.y * 719.0; - let seed = hash(cellHashSeed + f32(companion) * 17.0); - let cellJitter = vec2f(hash(cellHashSeed + 11.0), hash(cellHashSeed + 23.0)); - let base = (cell + cellJitter) / f32(span); + let particleInCell = localPair % band.particlesPerCell; + let cellOrdinal = localPair / band.particlesPerCell; + let cellX = band.minX + (cellOrdinal % band.cellsX); + let cellY = band.minY + (cellOrdinal / band.cellsX); + let cell = vec2f(f32(cellX), f32(cellY)); + let offset = 65535u; + let seedU = hashPair(level * 73856093u + cellX * 19349663u + particleInCell * 119954089u, cellY * 83492791u + companion * 2654435761u); + var rngState = hashPair(cellX + offset + particleInCell * 251u, cellY + offset + level * 509u); + let firstRandom = randomFirst(cellX + offset + particleInCell * 251u, cellY + offset + level * 509u); + rngState = xorshift(rngState); + let period = 5.2 + randomValue(rngState) * 1.8; + rngState = xorshift(rngState); + let jitterX = perlinNoise1D(randomValue(rngState) * 1024.0, seedU ^ 17u); + rngState = xorshift(rngState); + let jitterY = perlinNoise1D(randomValue(rngState) * 1024.0, seedU ^ 29u); + rngState = xorshift(rngState); + let triSeed = randomValue(rngState); + rngState = xorshift(rngState); + let sizeRandom = randomValue(rngState); + let lifetime = fract(uniforms.time / period + firstRandom); + let spacing = 1.0 / max(f32(span), 1.0); + let base = clamp( + (cell + vec2f(0.5)) * spacing + + vec2f(jitterX, jitterY) * spacing * 0.58, + vec2f(0.001), + vec2f(0.999) + ); let sourceAlbedo = sampleAlbedo(base); var packed = samplePacked(base); - let levelDetail = f32(level) / max(f32(activeLevel), 1.0); + let levelDetail = f32(level) / f32(${STARDUST_MAX_LEVEL}u); let detail = clamp(uniforms.detail, 0.75, 2.0); let fieldVector = packed.rg * 2.0 - vec2f(1.0); let fieldDirection = normalize(fieldVector + vec2f(0.0001, 0.0)); - let drift = fieldDirection * uniforms.time * (uniforms.flowGain / 24.0) * (0.00055 + packed.a * 0.0011 + packed.b * 0.0004) / max(f32(span), 1.0); - var uv = wrap2(base + drift, vec2f(1.0)); - packed = samplePacked(uv); - - let curvatureGradient = channelGradient(uv, 2u); + let curvatureGradient = channelGradient(base, 2u); let tangent = normalize(vec2f(-curvatureGradient.y, curvatureGradient.x) + fieldDirection * 0.28 + vec2f(0.0001, 0.0)); - let fieldBlend = normalize(fieldDirection * 0.74 + tangent * (0.26 + packed.b * 0.18)); + let fieldBlend = normalize(fieldDirection * 0.62 + tangent * (0.32 + packed.b * 0.22) + triFlow(base * 18.0 - vec2f(0.0, uniforms.time * 0.035), triSeed) * 0.22); var nodePush = vec2f(0.0); var nodeHeat = 0.0; for (var envelopeIndex = 0u; envelopeIndex < ${MAX_NODE_ENVELOPES}u; envelopeIndex = envelopeIndex + 1u) { @@ -239,7 +329,7 @@ fn updateParticles(@builtin(global_invocation_id) id: vec3u) { break; } let envelope = nodeEnvelopes[envelopeIndex]; - let delta = uv - envelope.xy; + let delta = base - envelope.xy; let radius = max(envelope.z, 0.008); let normalized = dot(delta, delta) / (radius * radius); let edge = exp(-4.0); @@ -251,31 +341,19 @@ fn updateParticles(@builtin(global_invocation_id) id: vec3u) { nodeHeat += influence; } - let period = 5.2 + hash(seed * 53.0) * 1.8 + levelDetail * 0.8; - let phase = uniforms.time / period + seed; - let wave = phase * 6.28318; - let sine = sin(wave) * 0.5 + 0.5; - let cosine = cos(wave) * 0.5 + 0.5; - let fade = select(sine * sine, cosine * cosine, companion == 1u); - let side = select(-1.0, 1.0, companion == 1u); - let shimmer = sin(wave * 1.7 + packed.b * 4.0) * 0.5 + 0.5; - let flowLine = normalize(fieldBlend + nodePush + proceduralCurl(uv * 54.0, seed) * 0.045); - let normal = vec2f(-flowLine.y, flowLine.x); - let worldSpan = max(max(uniforms.worldWidth, uniforms.worldHeight), 1.0); - let cellUv = 1.0 / max(f32(span), 1.0); - let linePixels = (3.0 + packed.a * 8.5 + packed.b * 4.0 + nodeHeat * 2.0) * (0.94 + detail * 0.05); - let lineUv = min(linePixels / worldSpan, cellUv * 0.28); - let trace = (side * 0.42 + (fract(phase) - 0.5) * 0.32) * lineUv; - let tremble = (shimmer - 0.5) * lineUv * 0.26; - uv = clamp(uv + flowLine * trace + normal * tremble, vec2f(0.001), vec2f(0.999)); + let flowLine = normalize(fieldBlend + nodePush); + var uv = clamp(base - flowLine * lifetime * (uniforms.flowGain / 24.0) * (0.009 + packed.a * 0.028 + packed.b * 0.012) * spacing, vec2f(0.001), vec2f(0.999)); packed = samplePacked(uv); particle.position = uv; - particle.velocity = flowLine * (linePixels * 0.42 + 1.0); - particle.life = fade; + particle.velocity = flowLine * (4.0 + packed.a * 9.0 + packed.b * 4.0); + particle.life = lifetime; let flowAxis = abs(fieldVector.x - fieldVector.y); - let heat = clamp(packed.a * 0.78 + packed.b * 0.62 + nodeHeat * 0.38 + fade * 0.18, 0.0, 1.0); + let lifeFade = parabola(lifetime, 2.0); + let phase = lifetime * 6.28318530718 + f32(companion) * 1.57079632679; + let pairFade = pow(clamp(sin(phase) * 0.5 + 0.5, 0.0, 1.0), 1.35); + let heat = clamp(packed.a * 0.78 + packed.b * 0.62 + nodeHeat * 0.38 + lifeFade * 0.18, 0.0, 1.0); let brass = clamp((packed.b - packed.a * 0.32) * 1.35 + flowAxis * 0.18, 0.0, 1.0); let cyan = vec3f(0.08 + heat * 0.28, 0.44 + heat * 0.46, 0.52 + heat * 0.62); let gold = vec3f(0.86, 0.58, 0.22); @@ -285,9 +363,9 @@ fn updateParticles(@builtin(global_invocation_id) id: vec3u) { let rgb = mix(albedoLift, fieldTint, 0.18 + heat * 0.16); particle.color = vec4f( rgb, - uniforms.alpha * (0.0022 + heat * 0.008 + packed.a * 0.005 + nodeHeat * 0.0025) * fade * (0.72 + levelDetail * 0.28) + uniforms.alpha * (0.003 + heat * 0.010 + packed.a * 0.005 + nodeHeat * 0.0025) * lifeFade * pairFade * band.weight * (0.72 + levelDetail * 0.28) ); - particle.size = (0.3 + hash(seed * 19.0) * 0.68) * (0.68 + packed.a * 0.52 + packed.b * 0.24 + nodeHeat * 0.14) * (0.92 + detail * 0.035) * (0.82 + levelDetail * 0.22); + particle.size = mix(0.24, 0.92, sizeRandom) * (0.7 + packed.a * 0.7 + packed.b * 0.28 + nodeHeat * 0.14) * (0.92 + detail * 0.035) * (0.82 + levelDetail * 0.22) * lifeFade * (0.35 + pairFade * 0.65) * (0.82 + band.weight * 0.18); particles[index] = particle; } `; @@ -740,7 +818,6 @@ async function startGpuParticleField(canvas: HTMLCanvasElement, sample: FieldSam const dt = Math.min(0.033, Math.max(0.001, (time - lastTime) / 1000)); lastTime = time; detail += (targetDetail - detail) * Math.min(1, dt * 4.8); - const activeParticleCount = PARTICLE_COUNT; const graphTransform = transformState.received ? transformState.current : { @@ -750,6 +827,7 @@ async function startGpuParticleField(canvas: HTMLCanvasElement, sample: FieldSam bounds: { x: 0, y: 0, width: canvas.width, height: canvas.height }, }; const bounds = fitArtworkBounds(graphTransform.bounds); + const activeParticleCount = estimateVisibleParticleCount(graphTransform, bounds, canvas, detail, PARTICLE_COUNT); device.queue.writeBuffer(uniformBuffer, 0, new Float32Array([ time / 1000, dt, @@ -961,6 +1039,58 @@ function fitArtworkBounds(bounds: GraphViewportTransform["bounds"]) { }; } +function estimateVisibleParticleCount( + transform: GraphViewportTransform, + bounds: ReturnType, + canvas: HTMLCanvasElement, + detail: number, + capacity: number, +): number { + const pairCapacity = Math.max(1, Math.floor(capacity / 2)); + const levelValue = stardustLevelValue(transform, bounds, detail); + const coarseLevel = Math.floor(levelValue); + const fineLevel = Math.min(coarseLevel + 1, STARDUST_MAX_LEVEL); + const coarsePairs = visibleCellCount(transform, bounds, canvas, coarseLevel) * STARDUST_COARSE_PARTICLES_PER_CELL; + const finePairs = visibleCellCount(transform, bounds, canvas, fineLevel) * STARDUST_FINE_PARTICLES_PER_CELL; + const reservedCoarsePairs = Math.min(coarsePairs, Math.max(1, Math.floor(pairCapacity / 3))); + const reservedFinePairs = Math.min(finePairs, pairCapacity - reservedCoarsePairs); + const activePairs = Math.max(1, reservedCoarsePairs + reservedFinePairs); + return Math.min(capacity, activePairs * 2); +} + +function stardustLevelValue( + transform: GraphViewportTransform, + bounds: ReturnType, + detail: number, +): number { + const screenSide = Math.max(bounds.width, bounds.height) * Math.max(transform.scale, 0.0001); + return Math.max(4, Math.min(STARDUST_MAX_LEVEL, Math.log2(Math.max(screenSide / 7, 1)) + detail * 0.72)); +} + +function visibleCellCount( + transform: GraphViewportTransform, + bounds: ReturnType, + canvas: HTMLCanvasElement, + level: number, +): number { + const scale = Math.max(transform.scale, 0.0001); + const worldMinX = (0 - transform.x) / scale; + const worldMinY = (0 - transform.y) / scale; + const worldMaxX = (canvas.width - transform.x) / scale; + const worldMaxY = (canvas.height - transform.y) / scale; + const uvMinX = clamp01((Math.min(worldMinX, worldMaxX) - bounds.x) / bounds.width); + const uvMinY = clamp01((Math.min(worldMinY, worldMaxY) - bounds.y) / bounds.height); + const uvMaxX = clamp01((Math.max(worldMinX, worldMaxX) - bounds.x) / bounds.width); + const uvMaxY = clamp01((Math.max(worldMinY, worldMaxY) - bounds.y) / bounds.height); + const span = 1 << level; + const margin = 1 / span; + const minX = Math.min(span - 1, Math.floor(Math.max(0, uvMinX - margin) * span)); + const minY = Math.min(span - 1, Math.floor(Math.max(0, uvMinY - margin) * span)); + const maxX = Math.min(span, Math.ceil(Math.min(1, uvMaxX + margin) * span)); + const maxY = Math.min(span, Math.ceil(Math.min(1, uvMaxY + margin) * span)); + return Math.max(1, maxX - minX) * Math.max(1, maxY - minY); +} + function resizeGpuCanvas(canvas: HTMLCanvasElement): void { const rect = canvas.getBoundingClientRect(); const scale = window.devicePixelRatio || 1; diff --git a/inspector/src/main.tsx b/inspector/src/main.tsx index f3a7ad8..5ad73ea 100644 --- a/inspector/src/main.tsx +++ b/inspector/src/main.tsx @@ -8,17 +8,15 @@ import "./styles.css"; type Selection = { record: number; - catalog: number; }; type RawSelection = | { kind: "record"; index: number } - | { kind: "catalog"; index: number }; + | { kind: "value"; recordIndex: number; path: string; value: unknown }; type GraphProjection = { state: EpiphanyGraphsState; recordNodeIds: string[]; - catalogNodeIds: string[]; nodeSelections: Map; edgeSelections: Map; truncatedValueNodes: number; @@ -30,7 +28,7 @@ type TerrainTexture = { rgba: Uint8ClampedArray; }; -const MAX_EXPANDED_VALUE_NODES = 320; +const MAX_EXPANDED_VALUE_NODES = 960; const assetPath = (name: string) => `${import.meta.env.BASE_URL}${name}`; const HUGINN_ART = { surface: assetPath("huginn-surface.png"), @@ -93,25 +91,28 @@ createRoot(app).render( function HuginnApp() { const [inspection, setInspection] = useState(); - const [selection, setSelection] = useState({ record: 0, catalog: 0 }); + const [selection, setSelection] = useState({ record: 0 }); const [graphSelection, setGraphSelection] = useState(null); const [errorMessage, setErrorMessage] = useState(""); const [dragging, setDragging] = useState(false); const graphProjection = useMemo( - () => inspection ? buildGraphProjection(inspection, selection.record) : emptyGraphProjection(), - [inspection, selection.record], + () => inspection ? buildGraphProjection(inspection) : emptyGraphProjection(), + [inspection], ); async function inspectFile(file: File): Promise { try { const bytes = new Uint8Array(await file.arrayBuffer()); - setInspection(inspectCultCacheBytes(file.name, bytes, file.size)); - setSelection({ record: 0, catalog: 0 }); + const nextInspection = inspectCultCacheBytes(file.name, bytes, file.size); + setInspection(nextInspection); + setSelection({ record: 0 }); setGraphSelection({ kind: "node", graphKey: "dataflow", - nodeId: "store", + nodeId: nextInspection.records[0] + ? recordNodeId(nextInspection.records[0], 0) + : "store", }); setErrorMessage(""); } catch (error) { @@ -279,8 +280,6 @@ function InspectionView({ onInspectFile: (file: File) => Promise; }) { const terrainForces = useHuginnTerrainForces(HUGINN_ART.field); - const record = inspection.records[selection.record]; - const catalogEntry = inspection.catalog[selection.catalog]; const expandedRawSelection = graphSelection?.kind === "node" ? graphProjection.nodeSelections.get(graphSelection.nodeId) : graphSelection?.kind === "edge" @@ -288,9 +287,17 @@ function InspectionView({ : undefined; const expandedRecord = expandedRawSelection?.kind === "record" ? inspection.records[expandedRawSelection.index] + : expandedRawSelection?.kind === "value" + ? inspection.records[expandedRawSelection.recordIndex] + : undefined; + const expandedValue = expandedRawSelection?.kind === "value" + ? { + path: expandedRawSelection.path, + value: expandedRawSelection.value, + } : undefined; - const expandedCatalogEntry = expandedRawSelection?.kind === "catalog" - ? inspection.catalog[expandedRawSelection.index] + const expandedCatalogEntry = expandedRecord + ? inspection.catalog.find((entry) => entry.schemaId === expandedRecord.schemaId) : undefined; const expandedNode = graphSelection?.kind === "node" ? { @@ -301,29 +308,19 @@ function InspectionView({ content: ( ), } : undefined; - const selectRaw = (next: RawSelection) => { - if (next.kind === "record") { - setSelection({ ...selection, record: next.index }); - setGraphSelection({ - kind: "node", - graphKey: "dataflow", - nodeId: graphProjection.recordNodeIds[next.index], - }); - return; - } - - setSelection({ ...selection, catalog: next.index }); + const selectRaw = (next: Extract) => { + setSelection({ record: next.index }); setGraphSelection({ kind: "node", graphKey: "dataflow", - nodeId: graphProjection.catalogNodeIds[next.index], + nodeId: graphProjection.recordNodeIds[next.index], }); }; const selectGraph = (next: ViewerSelection | null) => { @@ -339,9 +336,14 @@ function InspectionView({ return; } - setSelection(rawSelection.kind === "record" - ? { ...selection, record: rawSelection.index } - : { ...selection, catalog: rawSelection.index }); + if (rawSelection.kind === "record") { + setSelection({ record: rawSelection.index }); + return; + } + if (rawSelection.kind === "value") { + setSelection({ record: rawSelection.recordIndex }); + return; + } }; return ( @@ -362,12 +364,12 @@ function InspectionView({ focusSelection selectionFocusMode="article" expandedNode={expandedNode} - graphLabels={{ architecture: "File", dataflow: "Payload" }} + graphLabels={{ architecture: "File", dataflow: "Payload Cloud" }} style={{ minHeight: "100vh" }} /> {graphProjection.truncatedValueNodes > 0 ? (
- Payload tree clipped after {MAX_EXPANDED_VALUE_NODES} value nodes; {graphProjection.truncatedValueNodes} deeper node(s) omitted from the graph. Raw payload detail remains below. + Payload cloud clipped after {MAX_EXPANDED_VALUE_NODES} value nodes; {graphProjection.truncatedValueNodes} deeper node(s) omitted from the graph. Raw payload detail remains in the expanded record panel.
) : null} @@ -394,35 +396,6 @@ function InspectionView({ :
No records
} -
- -
- {inspection.catalog.length - ? inspection.catalog.map((entry, index) => ( - selectRaw({ kind: "catalog", index })} - /> - )) - :
No schema catalog
} -
-
-
- -
-
-

Record

- {record ? :
No record selected
} -
-
-

Schema

- {catalogEntry ? :
No catalog entry selected
} -
-
-
); @@ -531,29 +504,34 @@ function PanelHeader({ title, count }: { title: string; count: string }) { function ExpandedNodePanel({ catalogEntry, + expandedValue, inspection, record, - selection, }: { catalogEntry: InspectedCatalogEntry | undefined; + expandedValue: { path: string; value: unknown } | undefined; inspection: CultCacheInspection; record: InspectedRecord | undefined; - selection: Selection; }) { - const title = record?.key ?? catalogEntry?.schemaName ?? inspection.filePath; + const title = expandedValue?.path ?? record?.key ?? catalogEntry?.schemaName ?? inspection.filePath; + const payloadValue = expandedValue?.value ?? record?.payloadPreview; return (
-
CultCache Node
+
{expandedValue ? "Payload Value" : "CultCache Node"}

{title}

{inspection.format} {record ? {record.schemaName} : {inspection.records.length} records} - {record ? {record.payloadBytes} bytes : {inspection.catalog.length} schemas} + {expandedValue + ? {valueStatus(expandedValue.value)} + : record ? {record.payloadBytes} bytes : {inspection.catalog.length} schemas}
- {record ? ( + {expandedValue && record ? ( +

{expandedValue.path} inside {record.key}.

+ ) : record ? ( <>

{record.schemaName} record stored at {record.storedAt}.

{record.payloadDecodeError ?
{record.payloadDecodeError}
: null} @@ -562,10 +540,10 @@ function ExpandedNodePanel({

{inspection.filePath} contains a schema catalog and persisted MessagePack record set.

)}
- {record ? ( + {payloadValue !== undefined ? (

Payload

- +
) : null}
@@ -579,7 +557,7 @@ function ExpandedNodePanel({
- Raw selection: record {selection.record + 1}, schema {selection.catalog + 1} + Record {record ? inspection.records.indexOf(record) + 1 : "-"} of {inspection.records.length}
); @@ -604,35 +582,6 @@ function RecordButton({ ); } -function CatalogButton({ - entry, - index, - selected, - onSelect, -}: { - entry: InspectedCatalogEntry; - index: number; - selected: boolean; - onSelect: () => void; -}) { - return ( - - ); -} - -function RecordDetail({ record }: { record: InspectedRecord }) { - return ( - <> - - {record.payloadDecodeError ?
{record.payloadDecodeError}
: null} - - - ); -} - function RecordFacts({ record }: { record: InspectedRecord }) { return ( {JSON.stringify(record.payloadPreview, null, 2)} +
{JSON.stringify(value, null, 2)}
); } @@ -682,11 +631,10 @@ function Facts({ rows }: { rows: Array<[string, string]> }) { ); } -function buildGraphProjection(inspection: CultCacheInspection, expandedRecordIndex: number): GraphProjection { +function buildGraphProjection(inspection: CultCacheInspection): GraphProjection { const dataflowNodes: EpiphanyGraphNode[] = []; const dataflowEdges: EpiphanyGraphEdge[] = []; const recordNodeIds: string[] = []; - const catalogNodeIds: string[] = []; const nodeSelections = new Map(); const edgeSelections = new Map(); const budget = { @@ -734,80 +682,48 @@ function buildGraphProjection(inspection: CultCacheInspection, expandedRecordInd dataflowNodes.push({ id: "store", title: ".cc Store", - purpose: `Root ${inspection.format} snapshot.`, + purpose: `Payload cloud for ${inspection.format} snapshot.`, mechanism: inspection.filePath, status: `${inspection.fileSizeBytes} bytes`, }); - - for (const [catalogIndex, entry] of inspection.catalog.entries()) { - const schemaNodeId = nodeId("schema", entry.schemaId); - catalogNodeIds[catalogIndex] = schemaNodeId; - nodeSelections.set(schemaNodeId, { kind: "catalog", index: catalogIndex }); - dataflowNodes.push({ - id: schemaNodeId, - title: entry.schemaName, - purpose: `Schema ${entry.schemaVersion} with ${entry.members.length} declared slot(s).`, - mechanism: entry.contentHash, - status: "schema", - }); - const catalogEdgeId = edgeId("store", schemaNodeId, "catalogs"); - edgeSelections.set(catalogEdgeId, { kind: "catalog", index: catalogIndex }); - dataflowEdges.push({ - id: catalogEdgeId, - source_id: "store", - target_id: schemaNodeId, - kind: "catalogs", - label: "catalog", - }); - } - inspection.records.forEach((record, recordIndex) => { - const recordNodeId = nodeId("record", `${record.schemaId}:${record.key}:${recordIndex}`); - const schemaNodeId = nodeId("schema", record.schemaId); - recordNodeIds[recordIndex] = recordNodeId; - nodeSelections.set(recordNodeId, { kind: "record", index: recordIndex }); + const currentRecordNodeId = recordNodeId(record, recordIndex); + const recordSelection: RawSelection = { kind: "record", index: recordIndex }; + const perRecordBudget = { + remainingValueNodes: Math.max(24, Math.floor(MAX_EXPANDED_VALUE_NODES / Math.max(1, inspection.records.length))), + truncatedValueNodes: 0, + }; + recordNodeIds[recordIndex] = currentRecordNodeId; + nodeSelections.set(currentRecordNodeId, recordSelection); dataflowNodes.push({ - id: recordNodeId, + id: currentRecordNodeId, title: record.key, - purpose: `Document record for ${record.schemaName}.`, - mechanism: `${record.payloadBytes} payload bytes stored at ${record.storedAt}`, - status: "record", + purpose: valuePurpose(record.payloadPreview), + mechanism: record.schemaName, + status: `${record.payloadBytes} bytes`, }); - const recordEdgeId = edgeId("store", recordNodeId, "contains"); - edgeSelections.set(recordEdgeId, { kind: "record", index: recordIndex }); + const recordEdgeId = edgeId("store", currentRecordNodeId, "contains"); + edgeSelections.set(recordEdgeId, recordSelection); dataflowEdges.push({ id: recordEdgeId, source_id: "store", - target_id: recordNodeId, + target_id: currentRecordNodeId, kind: "contains", label: "record", }); - if (dataflowNodes.some((node) => node.id === schemaNodeId)) { - const schemaEdgeId = edgeId(recordNodeId, schemaNodeId, "uses-schema"); - edgeSelections.set(schemaEdgeId, { kind: "record", index: recordIndex }); - dataflowEdges.push({ - id: schemaEdgeId, - source_id: recordNodeId, - target_id: schemaNodeId, - kind: "uses-schema", - label: "schema", - }); - } - - if (recordIndex === expandedRecordIndex) { - appendValueTree({ - nodes: dataflowNodes, - edges: dataflowEdges, - nodeSelections, - edgeSelections, - rawSelection: { kind: "record", index: recordIndex }, - parentId: recordNodeId, - value: record.payloadPreview, - path: "payload", - depth: 0, - budget, - }); - } + appendValueTree({ + nodes: dataflowNodes, + edges: dataflowEdges, + nodeSelections, + edgeSelections, + recordIndex, + parentId: currentRecordNodeId, + value: record.payloadPreview, + path: "payload", + depth: 0, + budget: perRecordBudget, + }); + budget.truncatedValueNodes += perRecordBudget.truncatedValueNodes; }); return { @@ -817,7 +733,6 @@ function buildGraphProjection(inspection: CultCacheInspection, expandedRecordInd links: [], }, recordNodeIds, - catalogNodeIds, nodeSelections, edgeSelections, truncatedValueNodes: budget.truncatedValueNodes, @@ -829,7 +744,7 @@ function appendValueTree({ edges, nodeSelections, edgeSelections, - rawSelection, + recordIndex, parentId, value, path, @@ -840,7 +755,7 @@ function appendValueTree({ edges: EpiphanyGraphEdge[]; nodeSelections: Map; edgeSelections: Map; - rawSelection: RawSelection; + recordIndex: number; parentId: string; value: unknown; path: string; @@ -859,12 +774,13 @@ function appendValueTree({ } const childPath = `${path}[${index}]`; const childId = nodeId("value", `${parentId}:${childPath}`); + const rawSelection: RawSelection = { kind: "value", recordIndex, path: childPath, value: item }; nodeSelections.set(childId, rawSelection); nodes.push(valueNode(childId, `[${index}]`, item, childPath)); const edge = valueEdge(parentId, childId, "slot", index.toString()); edgeSelections.set(edge.id!, rawSelection); edges.push(edge); - appendValueTree({ nodes, edges, nodeSelections, edgeSelections, rawSelection, parentId: childId, value: item, path: childPath, depth: depth + 1, budget }); + appendValueTree({ nodes, edges, nodeSelections, edgeSelections, recordIndex, parentId: childId, value: item, path: childPath, depth: depth + 1, budget }); }); return; } @@ -876,12 +792,13 @@ function appendValueTree({ } const childPath = `${path}.${key}`; const childId = nodeId("value", `${parentId}:${childPath}`); + const rawSelection: RawSelection = { kind: "value", recordIndex, path: childPath, value: item }; nodeSelections.set(childId, rawSelection); nodes.push(valueNode(childId, key, item, childPath)); const edge = valueEdge(parentId, childId, "field", key); edgeSelections.set(edge.id!, rawSelection); edges.push(edge); - appendValueTree({ nodes, edges, nodeSelections, edgeSelections, rawSelection, parentId: childId, value: item, path: childPath, depth: depth + 1, budget }); + appendValueTree({ nodes, edges, nodeSelections, edgeSelections, recordIndex, parentId: childId, value: item, path: childPath, depth: depth + 1, budget }); } } } @@ -912,13 +829,29 @@ function countExpandableChildren(value: unknown): number { function valueNode(id: string, title: string, value: unknown, path: string): EpiphanyGraphNode { return { id, - title, + title: valueTitle(title, value), purpose: valuePurpose(value), mechanism: path, status: valueStatus(value), }; } +function valueTitle(title: string, value: unknown): string { + if (Array.isArray(value) || isPlainObject(value)) { + return title; + } + if (value === null) { + return `${title}: null`; + } + const rendered = typeof value === "string" ? value : JSON.stringify(value) ?? String(value); + const compact = rendered.length > 72 ? `${rendered.slice(0, 69)}...` : rendered; + return `${title}: ${compact}`; +} + +function recordNodeId(record: InspectedRecord, index: number): string { + return nodeId("record", `${record.schemaId}:${record.key}:${index}`); +} + function valueEdge(sourceId: string, targetId: string, kind: string, label: string): EpiphanyGraphEdge { return { id: edgeId(sourceId, targetId, kind), @@ -960,7 +893,6 @@ function emptyGraphProjection(): GraphProjection { links: [], }, recordNodeIds: [], - catalogNodeIds: [], nodeSelections: new Map(), edgeSelections: new Map(), truncatedValueNodes: 0, diff --git a/inspector/src/styles.css b/inspector/src/styles.css index 63ab049..884bfe7 100644 --- a/inspector/src/styles.css +++ b/inspector/src/styles.css @@ -288,28 +288,8 @@ code { transform: translateY(calc(100% - 48px)); } -.catalog-panel { - right: 16px; - bottom: 16px; - width: min(360px, calc(50vw - 24px)); - max-height: min(44vh, 420px); - transform: translateY(calc(100% - 48px)); -} - -.detail-panel { - top: 112px; - right: 16px; - width: min(480px, calc(100vw - 32px)); - max-height: calc(100vh - 152px); - transform: translateX(calc(100% - 56px)); -} - .records-panel:hover, -.records-panel:focus-within, -.catalog-panel:hover, -.catalog-panel:focus-within, -.detail-panel:hover, -.detail-panel:focus-within { +.records-panel:focus-within { transform: translate(0, 0); } @@ -436,38 +416,6 @@ code { font-size: 12px; } -.detail { - min-height: 360px; -} - -.detail-panel { - overflow: hidden; -} - -.detail-tabs { - display: grid; - gap: 10px; - max-height: calc(100vh - 238px); - overflow: auto; - padding: 10px; -} - -.detail-tabs section { - min-width: 0; -} - -.detail-tabs h3 { - margin: 4px 2px 8px; - color: #dffbff; - font-size: 13px; -} - -.detail > dl, -.detail > pre, -.detail > .error { - margin: 12px; -} - dl { display: grid; grid-template-columns: 120px minmax(0, 1fr); @@ -741,8 +689,7 @@ pre { @container (max-width: 180px) { .summary, .dropzone, - .list, - .detail-tabs { + .list { display: none; } @@ -762,8 +709,7 @@ pre { transform: translateX(calc(-100% + 58px)); } - .records-panel, - .catalog-panel { + .records-panel { left: 12px; right: 12px; width: auto; @@ -771,23 +717,9 @@ pre { } .records-panel { - bottom: 76px; - } - - .catalog-panel { bottom: 12px; } - .detail-panel { - top: auto; - right: 12px; - bottom: 12px; - left: 12px; - width: auto; - max-height: 46vh; - transform: translateY(calc(100% - 54px)); - } - .expanded-article { grid-template-columns: 1fr; } diff --git a/package-lock.json b/package-lock.json index 507f8f8..360723d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cultcache-ts", - "version": "0.12.0", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cultcache-ts", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { "@msgpack/msgpack": "^3.1.3", diff --git a/package.json b/package.json index b668536..ea2a594 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cultcache-ts", - "version": "0.12.0", + "version": "0.13.0", "description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.", "main": "dist/index.js", "types": "dist/index.d.ts",