Make Huginn payload cloud and stardust viewport native
This commit is contained in:
parent
1feea63f63
commit
8b36467c09
6 changed files with 344 additions and 346 deletions
16
README.md
16
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<storage, read_write> particles: array<Particle>;
|
||||
@group(0) @binding(1) var<uniform> uniforms: SimUniforms;
|
||||
@group(0) @binding(2) var fieldSampler: sampler;
|
||||
|
|
@ -104,67 +118,85 @@ struct SimUniforms {
|
|||
@group(0) @binding(4) var<storage, read> nodeEnvelopes: array<vec4f>;
|
||||
@group(0) @binding(5) var albedoTexture: texture_2d<f32>;
|
||||
|
||||
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<typeof fitArtworkBounds>,
|
||||
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<typeof fitArtworkBounds>,
|
||||
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<typeof fitArtworkBounds>,
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<string, RawSelection>;
|
||||
edgeSelections: Map<string, RawSelection>;
|
||||
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<CultCacheInspection | undefined>();
|
||||
const [selection, setSelection] = useState<Selection>({ record: 0, catalog: 0 });
|
||||
const [selection, setSelection] = useState<Selection>({ record: 0 });
|
||||
const [graphSelection, setGraphSelection] = useState<ViewerSelection | null>(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<void> {
|
||||
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<void>;
|
||||
}) {
|
||||
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: (
|
||||
<ExpandedNodePanel
|
||||
catalogEntry={expandedCatalogEntry}
|
||||
expandedValue={expandedValue}
|
||||
inspection={inspection}
|
||||
record={expandedRecord}
|
||||
selection={selection}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: 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<RawSelection, { kind: "record" }>) => {
|
||||
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 ? (
|
||||
<div className="graph-warning">
|
||||
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.
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
|
@ -394,35 +396,6 @@ function InspectionView({
|
|||
: <div className="empty">No records</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="floating-panel data-panel catalog-panel">
|
||||
<PanelHeader title="Schema Catalog" count={inspection.catalog.length.toString()} />
|
||||
<div className="list">
|
||||
{inspection.catalog.length
|
||||
? inspection.catalog.map((entry, index) => (
|
||||
<CatalogButton
|
||||
key={entry.schemaId}
|
||||
entry={entry}
|
||||
index={index}
|
||||
selected={index === selection.catalog}
|
||||
onSelect={() => selectRaw({ kind: "catalog", index })}
|
||||
/>
|
||||
))
|
||||
: <div className="empty">No schema catalog</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="floating-panel detail-panel">
|
||||
<PanelHeader title="Selected Raw View" count={record?.key ?? catalogEntry?.schemaVersion ?? ""} />
|
||||
<div className="detail-tabs">
|
||||
<section>
|
||||
<h3>Record</h3>
|
||||
{record ? <RecordDetail record={record} /> : <div className="empty">No record selected</div>}
|
||||
</section>
|
||||
<section>
|
||||
<h3>Schema</h3>
|
||||
{catalogEntry ? <CatalogDetail entry={catalogEntry} /> : <div className="empty">No catalog entry selected</div>}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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 (
|
||||
<article className="expanded-content">
|
||||
<div className="expanded-kicker">CultCache Node</div>
|
||||
<div className="expanded-kicker">{expandedValue ? "Payload Value" : "CultCache Node"}</div>
|
||||
<header className="expanded-header">
|
||||
<h1>{title}</h1>
|
||||
<div className="expanded-chips">
|
||||
<span>{inspection.format}</span>
|
||||
{record ? <span>{record.schemaName}</span> : <span>{inspection.records.length} records</span>}
|
||||
{record ? <span>{record.payloadBytes} bytes</span> : <span>{inspection.catalog.length} schemas</span>}
|
||||
{expandedValue
|
||||
? <span>{valueStatus(expandedValue.value)}</span>
|
||||
: record ? <span>{record.payloadBytes} bytes</span> : <span>{inspection.catalog.length} schemas</span>}
|
||||
</div>
|
||||
</header>
|
||||
<section className="expanded-summary">
|
||||
{record ? (
|
||||
{expandedValue && record ? (
|
||||
<p>{expandedValue.path} inside {record.key}.</p>
|
||||
) : record ? (
|
||||
<>
|
||||
<p>{record.schemaName} record stored at {record.storedAt}.</p>
|
||||
{record.payloadDecodeError ? <div className="error">{record.payloadDecodeError}</div> : null}
|
||||
|
|
@ -562,10 +540,10 @@ function ExpandedNodePanel({
|
|||
<p>{inspection.filePath} contains a schema catalog and persisted MessagePack record set.</p>
|
||||
)}
|
||||
</section>
|
||||
{record ? (
|
||||
{payloadValue !== undefined ? (
|
||||
<section className="expanded-payload">
|
||||
<h2>Payload</h2>
|
||||
<RecordPayload record={record} />
|
||||
<PayloadCode value={payloadValue} />
|
||||
</section>
|
||||
) : null}
|
||||
<section className="expanded-article">
|
||||
|
|
@ -579,7 +557,7 @@ function ExpandedNodePanel({
|
|||
</div>
|
||||
</section>
|
||||
<footer className="expanded-footer">
|
||||
Raw selection: record {selection.record + 1}, schema {selection.catalog + 1}
|
||||
Record {record ? inspection.records.indexOf(record) + 1 : "-"} of {inspection.records.length}
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
|
|
@ -604,35 +582,6 @@ function RecordButton({
|
|||
);
|
||||
}
|
||||
|
||||
function CatalogButton({
|
||||
entry,
|
||||
index,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
entry: InspectedCatalogEntry;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className="row" type="button" data-catalog={index} aria-selected={selected} onClick={onSelect}>
|
||||
<strong>{entry.schemaName}</strong>
|
||||
<span>{entry.schemaId}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordDetail({ record }: { record: InspectedRecord }) {
|
||||
return (
|
||||
<>
|
||||
<RecordFacts record={record} />
|
||||
{record.payloadDecodeError ? <div className="error">{record.payloadDecodeError}</div> : null}
|
||||
<RecordPayload record={record} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordFacts({ record }: { record: InspectedRecord }) {
|
||||
return (
|
||||
<Facts rows={[
|
||||
|
|
@ -645,9 +594,9 @@ function RecordFacts({ record }: { record: InspectedRecord }) {
|
|||
);
|
||||
}
|
||||
|
||||
function RecordPayload({ record }: { record: InspectedRecord }) {
|
||||
function PayloadCode({ value }: { value: unknown }) {
|
||||
return (
|
||||
<pre className="payload-code">{JSON.stringify(record.payloadPreview, null, 2)}</pre>
|
||||
<pre className="payload-code">{JSON.stringify(value, null, 2)}</pre>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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<string, RawSelection>();
|
||||
const edgeSelections = new Map<string, RawSelection>();
|
||||
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<string, RawSelection>;
|
||||
edgeSelections: Map<string, RawSelection>;
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue