mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
* chore(catalog): publish generated catalog * chore(catalog): publish generated catalog * chore(catalog): publish generated catalog * chore(catalog): publish generated catalog * chore(catalog): publish generated catalog * chore(catalog): publish generated catalog * chore(catalog): publish generated catalog * chore(catalog): publish generated catalog * chore(catalog): publish generated catalog * chore(catalog): publish generated catalog * chore(catalog): publish generated catalog --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
481 lines
18 KiB
Plaintext
481 lines
18 KiB
Plaintext
---
|
||
title: "Exploded Stack"
|
||
description: "Isometric layer plates explode apart with a label beside each layer, hold, then collapse back into one stack. Built for architecture and system diagrams."
|
||
mode: "frame"
|
||
---
|
||
|
||
import { CatalogDetail, CatalogSlot } from "/snippets/catalog-detail.jsx";
|
||
import { InstallCommand } from "/snippets/install-command.jsx";
|
||
|
||
<CatalogDetail
|
||
previewSrc="/public/catalog/blocks/exploded-stack.json"
|
||
compositionId="exploded-stack"
|
||
compositionSrc="compositions/exploded-stack.html"
|
||
title="Exploded Stack"
|
||
description="Isometric layer plates explode apart with a label beside each layer, hold, then collapse back into one stack. Built for architecture and system diagrams."
|
||
variables={[{"id":"title","type":"string","role":"content","label":"Title","default":"How a request moves","maxLength":32},{"id":"layer_1","type":"string","role":"content","label":"Top layer","default":"Interface","maxLength":22},{"id":"layer_2","type":"string","role":"content","label":"Layer 2","default":"API gateway","maxLength":22},{"id":"layer_3","type":"string","role":"content","label":"Layer 3","default":"Services","maxLength":22},{"id":"layer_4","type":"string","role":"content","label":"Bottom layer","default":"Data","maxLength":22},{"id":"accent","type":"color","role":"style","label":"Accent","default":"#0a84ff"},{"id":"background","type":"color","role":"style","label":"Backdrop","default":"#f4f4f5"}]}
|
||
meta={{"duration":5,"width":1920,"height":1080,"category":"3D objects","badge":"Stable","codeLines":409}}
|
||
attribution={{"path":"registry/blocks/exploded-stack","tags":["3d-object","three-js","webgl","isometric","exploded-view","layers","diagram","architecture"]}}
|
||
hasCode
|
||
rawUrl="https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry/blocks/exploded-stack/exploded-stack.html"
|
||
>
|
||
|
||
<CatalogSlot slot="code">
|
||
|
||
```html exploded-stack.html
|
||
<!doctype html>
|
||
<!--
|
||
Exploded Stack: isometric layer plates sit as one stack, explode apart along the vertical
|
||
axis with labels drawing in beside each layer, hold, then collapse back, so the clip loops.
|
||
Seek-safe: every frame is render(t), driven by one linear GSAP clock.
|
||
-->
|
||
<html
|
||
lang="en"
|
||
data-composition-variables='[
|
||
{ "id": "title", "type": "string", "role": "content", "label": "Title", "default": "How a request moves", "maxLength": 32 },
|
||
{ "id": "layer_1", "type": "string", "role": "content", "label": "Top layer", "default": "Interface", "maxLength": 22 },
|
||
{ "id": "layer_2", "type": "string", "role": "content", "label": "Layer 2", "default": "API gateway", "maxLength": 22 },
|
||
{ "id": "layer_3", "type": "string", "role": "content", "label": "Layer 3", "default": "Services", "maxLength": 22 },
|
||
{ "id": "layer_4", "type": "string", "role": "content", "label": "Bottom layer", "default": "Data", "maxLength": 22 },
|
||
{ "id": "accent", "type": "color", "role": "style", "label": "Accent", "default": "#0a84ff" },
|
||
{ "id": "background", "type": "color", "role": "style", "label": "Backdrop", "default": "#f4f4f5" }
|
||
]'
|
||
>
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=1920, height=1080" />
|
||
<title>Exploded Stack</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||
<style>
|
||
@font-face {
|
||
font-family: "ES Inter";
|
||
font-weight: 500;
|
||
src: url(https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-500-normal.woff2)
|
||
format("woff2");
|
||
}
|
||
@font-face {
|
||
font-family: "ES Inter";
|
||
font-weight: 700;
|
||
src: url(https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-700-normal.woff2)
|
||
format("woff2");
|
||
}
|
||
html,
|
||
body {
|
||
margin: 0;
|
||
width: 1920px;
|
||
height: 1080px;
|
||
overflow: hidden;
|
||
background: #f4f4f5;
|
||
}
|
||
#es-root {
|
||
position: relative;
|
||
width: 1920px;
|
||
height: 1080px;
|
||
overflow: hidden;
|
||
background: var(--es-bg, #f4f4f5);
|
||
font-family: "ES Inter", system-ui, sans-serif;
|
||
}
|
||
#es-canvas,
|
||
#es-leaders {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 1920px;
|
||
height: 1080px;
|
||
display: block;
|
||
}
|
||
#es-title {
|
||
position: absolute;
|
||
left: 120px;
|
||
top: 110px;
|
||
font-size: 60px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.02em;
|
||
color: #14161c;
|
||
}
|
||
.es-label {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 18px;
|
||
font-size: 34px;
|
||
font-weight: 500;
|
||
color: #1b1e27;
|
||
white-space: nowrap;
|
||
opacity: 0;
|
||
}
|
||
.es-index {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.08em;
|
||
}
|
||
#es-leaders line {
|
||
stroke: rgba(27, 30, 39, 0.35);
|
||
stroke-width: 2;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div
|
||
id="es-root"
|
||
data-composition-id="exploded-stack"
|
||
data-start="0"
|
||
data-duration="5"
|
||
data-width="1920"
|
||
data-height="1080"
|
||
>
|
||
<canvas
|
||
id="es-canvas"
|
||
class="clip"
|
||
data-start="0"
|
||
data-duration="5"
|
||
data-track-index="1"
|
||
></canvas>
|
||
<svg id="es-leaders" viewBox="0 0 1920 1080" aria-hidden="true"></svg>
|
||
<div id="es-title"></div>
|
||
<div id="es-labels"></div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
var html = document.documentElement;
|
||
var defaults = {};
|
||
try {
|
||
JSON.parse(html.getAttribute("data-composition-variables") || "[]").forEach(function (v) {
|
||
defaults[v.id] = v.default;
|
||
});
|
||
} catch (error) {
|
||
/* malformed declaration: the scene module falls back to its own defaults */
|
||
}
|
||
var scoped =
|
||
(window.__hyperframes &&
|
||
window.__hyperframes.getVariables &&
|
||
window.__hyperframes.getVariables()) ||
|
||
{};
|
||
var vars = Object.assign({}, defaults, window.__hfVariables || {}, scoped);
|
||
var hex = function (v, fallback) {
|
||
return /^#[0-9a-f]{6}$/i.test(String(v)) ? String(v) : fallback;
|
||
};
|
||
var root = document.getElementById("es-root");
|
||
var duration = Math.max(0.001, parseFloat(root.dataset.duration) || 5);
|
||
root.style.setProperty("--es-bg", hex(vars.background, "#f4f4f5"));
|
||
document.getElementById("es-title").textContent = vars.title || "";
|
||
|
||
var names = [vars.layer_1, vars.layer_2, vars.layer_3, vars.layer_4].map(function (n, i) {
|
||
return String(n || ["Interface", "API gateway", "Services", "Data"][i]);
|
||
});
|
||
var labels = document.getElementById("es-labels");
|
||
var leaders = document.getElementById("es-leaders");
|
||
var svg = "http://www.w3.org/2000/svg";
|
||
names.forEach(function (name, i) {
|
||
var el = document.createElement("div");
|
||
el.className = "es-label";
|
||
el.innerHTML = '<span class="es-index">0' + (i + 1) + "</span><span></span>";
|
||
el.lastChild.textContent = name;
|
||
labels.appendChild(el);
|
||
var line = document.createElementNS(svg, "line");
|
||
var dot = document.createElementNS(svg, "circle");
|
||
dot.setAttribute("r", "6");
|
||
leaders.appendChild(line);
|
||
leaders.appendChild(dot);
|
||
});
|
||
|
||
var clock = { t: 0 };
|
||
var scene = {
|
||
vars: vars,
|
||
names: names,
|
||
duration: duration,
|
||
clock: clock,
|
||
hex: hex,
|
||
render: null,
|
||
};
|
||
window.__explodedStack = scene;
|
||
window.__hf = window.__hf || {};
|
||
window.__hf.buildReady = window.__hf.buildReady || {};
|
||
window.__hf.buildReady["exploded-stack"] = new Promise(function (resolve) {
|
||
scene.ready = resolve;
|
||
});
|
||
|
||
var tl = gsap.timeline({ paused: true });
|
||
tl.to(clock, {
|
||
t: duration,
|
||
duration: duration,
|
||
ease: "none",
|
||
onUpdate: function () {
|
||
if (scene.render) scene.render(clock.t);
|
||
},
|
||
});
|
||
window.__timelines = window.__timelines || {};
|
||
window.__timelines["exploded-stack"] = tl;
|
||
})();
|
||
</script>
|
||
|
||
<script type="module">
|
||
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.184.0/+esm";
|
||
import { RoundedBoxGeometry } from "https://cdn.jsdelivr.net/npm/three@0.184.0/examples/jsm/geometries/RoundedBoxGeometry.js/+esm";
|
||
|
||
const host = window.__explodedStack;
|
||
const V = host.vars;
|
||
const hex = host.hex;
|
||
const accent = new THREE.Color(hex(V.accent, "#0a84ff"));
|
||
const D = host.duration;
|
||
|
||
const renderer = new THREE.WebGLRenderer({
|
||
canvas: document.getElementById("es-canvas"),
|
||
antialias: true,
|
||
alpha: true,
|
||
preserveDrawingBuffer: true,
|
||
});
|
||
renderer.setPixelRatio(1);
|
||
renderer.setSize(1920, 1080, false);
|
||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||
|
||
const scene = new THREE.Scene();
|
||
// True isometric: orthographic, 45 degrees around Y, arctan(1/sqrt 2) down.
|
||
const view = 5.9;
|
||
const camera = new THREE.OrthographicCamera(
|
||
(-view * 16) / 9,
|
||
(view * 16) / 9,
|
||
view,
|
||
-view,
|
||
0.1,
|
||
100,
|
||
);
|
||
camera.position.set(10, 10.7, 10);
|
||
camera.lookAt(0, 0.7, 0);
|
||
// Slide along the camera's right axis so the stack sits left and labels get the right.
|
||
camera.position.add(new THREE.Vector3(1.9, 0, -1.9));
|
||
camera.updateMatrixWorld();
|
||
|
||
scene.add(new THREE.HemisphereLight(0xffffff, 0xd6d9e4, 2.2));
|
||
const sun = new THREE.DirectionalLight(0xffffff, 1.4);
|
||
sun.position.set(-4, 10, 6);
|
||
scene.add(sun);
|
||
|
||
const texture = (c) => {
|
||
const tex = new THREE.CanvasTexture(c);
|
||
tex.colorSpace = THREE.SRGBColorSpace;
|
||
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
||
return tex;
|
||
};
|
||
// White face with a hairline where the flat top meets the rounded edge.
|
||
function faceTexture() {
|
||
const c = document.createElement("canvas");
|
||
c.width = c.height = 512;
|
||
const g = c.getContext("2d");
|
||
g.fillStyle = "#ffffff";
|
||
g.fillRect(0, 0, 512, 512);
|
||
g.strokeStyle = "#d6d8dd";
|
||
g.lineWidth = 3;
|
||
g.strokeRect(1.5, 1.5, 509, 509);
|
||
return texture(c);
|
||
}
|
||
// White glyphs on transparent; the material colour tints them per frame.
|
||
function glyphTexture(i) {
|
||
const c = document.createElement("canvas");
|
||
c.width = c.height = 512;
|
||
const g = c.getContext("2d");
|
||
g.strokeStyle = g.fillStyle = "#ffffff";
|
||
g.lineWidth = 6;
|
||
// Each layer gets its own motif, so the plates read as different systems.
|
||
if (i === 0) {
|
||
for (let k = 0; k < 3; k++) g.strokeRect(70 + k * 130, 150, 110, 212);
|
||
} else if (i === 1) {
|
||
for (let k = 0; k < 5; k++) {
|
||
g.beginPath();
|
||
g.moveTo(80, 110 + k * 72);
|
||
g.lineTo(432, 110 + k * 72);
|
||
g.stroke();
|
||
}
|
||
} else if (i === 2) {
|
||
for (let y = 0; y < 3; y++)
|
||
for (let x = 0; x < 3; x++) g.fillRect(96 + x * 120, 96 + y * 120, 80, 80);
|
||
} else {
|
||
for (let k = 0; k < 3; k++) {
|
||
g.beginPath();
|
||
g.ellipse(256, 150 + k * 100, 150, 40, 0, 0, Math.PI * 2);
|
||
g.stroke();
|
||
}
|
||
}
|
||
return texture(c);
|
||
}
|
||
|
||
const LAYERS = 4;
|
||
const THICK = 0.22;
|
||
const GAP = 1.55;
|
||
const edge = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.6 });
|
||
const shadowCanvas = document.createElement("canvas");
|
||
shadowCanvas.width = shadowCanvas.height = 256;
|
||
const sg = shadowCanvas.getContext("2d");
|
||
const sgrad = sg.createRadialGradient(128, 128, 40, 128, 128, 128);
|
||
sgrad.addColorStop(0, "rgba(20,24,40,0.22)");
|
||
sgrad.addColorStop(1, "rgba(20,24,40,0)");
|
||
sg.fillStyle = sgrad;
|
||
sg.fillRect(0, 0, 256, 256);
|
||
const shadow = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(7, 7),
|
||
new THREE.MeshBasicMaterial({
|
||
map: new THREE.CanvasTexture(shadowCanvas),
|
||
transparent: true,
|
||
depthWrite: false,
|
||
}),
|
||
);
|
||
shadow.rotation.x = -Math.PI / 2;
|
||
shadow.position.y = -2.4;
|
||
scene.add(shadow);
|
||
const top = new THREE.MeshStandardMaterial({ map: faceTexture(), roughness: 0.7 });
|
||
const plates = [];
|
||
const glyphs = [];
|
||
for (let i = 0; i < LAYERS; i++) {
|
||
const plate = new THREE.Mesh(new RoundedBoxGeometry(4.2, THICK, 4.2, 4, 0.08), edge);
|
||
// 4.04 = the flat top inside the 0.08 rounded edge.
|
||
const face = new THREE.Mesh(new THREE.PlaneGeometry(4.04, 4.04), top);
|
||
face.rotation.x = -Math.PI / 2;
|
||
face.position.y = THICK / 2 + 0.002;
|
||
const glyph = new THREE.MeshStandardMaterial({
|
||
map: glyphTexture(i),
|
||
roughness: 0.7,
|
||
transparent: true,
|
||
depthWrite: false,
|
||
});
|
||
const motif = new THREE.Mesh(new THREE.PlaneGeometry(4.04, 4.04), glyph);
|
||
motif.rotation.x = -Math.PI / 2;
|
||
motif.position.y = THICK / 2 + 0.004;
|
||
plate.add(face, motif);
|
||
scene.add(plate);
|
||
plates.push(plate);
|
||
glyphs.push(glyph);
|
||
}
|
||
|
||
// Beats in seconds at the 5 s default; another duration scales them.
|
||
const BASE = 5;
|
||
const OPEN_AT = 0.3,
|
||
OPEN_FOR = 0.9,
|
||
OPEN_STAGGER = 0.06;
|
||
const LAND_AT = 1.25,
|
||
LAND_EVERY = 0.7,
|
||
DRAW = 0.3,
|
||
FULL = 0.4,
|
||
SETTLE = 0.3;
|
||
const CLOSE_AT = LAND_AT + (LAYERS - 1) * LAND_EVERY + 0.7;
|
||
const CLOSE_FOR = 0.6,
|
||
CLOSE_STAGGER = 0.05,
|
||
LIFT = 0.08;
|
||
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
||
const ease = (x) => {
|
||
const u = clamp01(x);
|
||
return u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2;
|
||
};
|
||
const easeOut = (x) => 1 - Math.pow(1 - clamp01(x), 3);
|
||
// Ease-out with a soft first frame, so the separation reads from OPEN_AT on.
|
||
const easeOpen = (x) => easeOut(Math.pow(clamp01(x), 1.4));
|
||
// 0 at rest, 1 from the moment layer i's label lands until FULL later.
|
||
const focus = (T, i) => {
|
||
const land = LAND_AT + i * LAND_EVERY;
|
||
return easeOut((T - land + DRAW) / DRAW) * (1 - ease((T - land - FULL) / SETTLE));
|
||
};
|
||
const GLYPH_REST = new THREE.Color(0xc9ccd3);
|
||
const INK_REST = new THREE.Color(0x8b909a);
|
||
const ink = new THREE.Color();
|
||
|
||
const labelEls = [...document.querySelectorAll(".es-label")];
|
||
const indexEls = [...document.querySelectorAll(".es-index")];
|
||
const lines = [...document.querySelectorAll("#es-leaders line")];
|
||
const dots = [...document.querySelectorAll("#es-leaders circle")];
|
||
const anchor = new THREE.Vector3();
|
||
const toScreen = (v) => {
|
||
const p = v.clone().project(camera);
|
||
return [(p.x * 0.5 + 0.5) * 1920, (-p.y * 0.5 + 0.5) * 1080];
|
||
};
|
||
|
||
host.render = (t) => {
|
||
const T = (t / D) * BASE;
|
||
for (let i = 0; i < LAYERS; i++) {
|
||
// Bottom plate stays put; the top plate travels furthest and leaves first.
|
||
const order = LAYERS - 1 - i;
|
||
const open =
|
||
easeOpen((T - OPEN_AT - i * OPEN_STAGGER) / OPEN_FOR) -
|
||
ease((T - CLOSE_AT - 0.05 - order * CLOSE_STAGGER) / CLOSE_FOR);
|
||
const w = focus(T, i);
|
||
const y = order * (THICK + 0.02 + open * GAP) - 2.2 + LIFT * w;
|
||
plates[i].position.y = y;
|
||
glyphs[i].color.copy(GLYPH_REST).lerp(accent, w);
|
||
const inkHex = `#${ink.copy(INK_REST).lerp(accent, w).getHexString()}`;
|
||
indexEls[i].style.color = inkHex;
|
||
dots[i].style.fill = inkHex;
|
||
|
||
const shown =
|
||
easeOut((T - LAND_AT - i * LAND_EVERY + DRAW) / DRAW) *
|
||
(1 - clamp01((T - CLOSE_AT) / 0.2));
|
||
// The plate's right-hand corner, where a label has open space beside it.
|
||
anchor.set(2.1, y, -2.1);
|
||
const [ax, ay] = toScreen(anchor);
|
||
const lx = ax + 90;
|
||
labelEls[i].style.opacity = shown.toFixed(3);
|
||
// Whole pixels: a fractional offset rasterises text differently by frame order.
|
||
labelEls[i].style.transform =
|
||
`translate(${Math.round(lx + 24 + (1 - shown) * 16)}px, ${Math.round(ay - 22)}px)`;
|
||
lines[i].setAttribute("x1", ax.toFixed(1));
|
||
lines[i].setAttribute("y1", ay.toFixed(1));
|
||
lines[i].setAttribute("x2", (ax + 90 * shown).toFixed(1));
|
||
lines[i].setAttribute("y2", ay.toFixed(1));
|
||
lines[i].style.opacity = shown > 0 ? 1 : 0;
|
||
dots[i].setAttribute("cx", ax.toFixed(1));
|
||
dots[i].setAttribute("cy", ay.toFixed(1));
|
||
dots[i].style.opacity = shown.toFixed(3);
|
||
}
|
||
renderer.render(scene, camera);
|
||
};
|
||
host.render(host.clock.t);
|
||
host.ready();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
</CatalogSlot>
|
||
|
||
<CatalogSlot slot="install">
|
||
|
||
<InstallCommand command="npx hyperframes add exploded-stack" item="exploded-stack" />
|
||
|
||
That writes one file: `compositions/exploded-stack.html`.
|
||
|
||
</CatalogSlot>
|
||
|
||
<CatalogSlot slot="docs">
|
||
|
||
## Add it to your video
|
||
|
||
It runs for 5 seconds at 1920×1080. Paste this into your composition:
|
||
|
||
```html index.html
|
||
<div
|
||
data-composition-id="exploded-stack"
|
||
data-composition-src="compositions/exploded-stack.html"
|
||
data-start="0"
|
||
data-duration="5"
|
||
data-track-index="1"
|
||
data-width="1920"
|
||
data-height="1080"
|
||
></div>
|
||
```
|
||
|
||
Move it in time with `data-start`. Put it on a different timeline row with
|
||
`data-track-index`. See [data attributes](/concepts/data-attributes) for the rest.
|
||
|
||
{/* hf:generated-footer */}
|
||
|
||
Tagged `3d-object` `three-js` `webgl` `isometric` `exploded-view` `layers` `diagram` `architecture`.
|
||
|
||
## Related topics
|
||
|
||
- [Browse the complete Catalog](/catalog)
|
||
- [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
|
||
- [Build a richer composition](/go-further)
|
||
|
||
</CatalogSlot>
|
||
|
||
</CatalogDetail>
|