Skip to content

Instantly share code, notes, and snippets.

@jpatel3
Created March 17, 2026 03:10
Show Gist options
  • Select an option

  • Save jpatel3/bc248a4c03e0074680c8efb87e6c4857 to your computer and use it in GitHub Desktop.

Select an option

Save jpatel3/bc248a4c03e0074680c8efb87e6c4857 to your computer and use it in GitHub Desktop.
Data-Driven Animations — Scaling Plan for Tuva Jr.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Data-Driven Animations — Scaling Plan | Tuva</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--tuva-green: #27ae60;
--tuva-green-light: #e8f8ef;
--tuva-green-dark: #1e8c4c;
--text: #1a1a2e;
--text-secondary: #4a4a6a;
--border: #e0e0e8;
--bg: #ffffff;
--bg-alt: #f7f8fa;
--code-bg: #f0f2f5;
--shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
}
html { font-size: 16px; scroll-behavior: smooth; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--text);
background: var(--bg-alt);
line-height: 1.7;
-webkit-font-smoothing: antialiased;
}
/* Header */
.site-header {
background: var(--bg);
border-bottom: 3px solid var(--tuva-green);
padding: 1.5rem 2rem;
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow);
}
.site-header-inner {
max-width: 900px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
}
.brand-logo {
width: 36px; height: 36px;
background: var(--tuva-green);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
color: white; font-weight: 700; font-size: 1.1rem;
}
.brand-text { font-size: 1.1rem; font-weight: 600; color: var(--text); }
.brand-text span { color: var(--text-secondary); font-weight: 400; }
.header-date { font-size: 0.85rem; color: var(--text-secondary); }
/* Main container */
.container {
max-width: 900px;
margin: 0 auto;
padding: 2rem 2rem 4rem;
}
/* Title block */
.title-block {
background: var(--bg);
border-radius: 12px;
padding: 2.5rem 2.5rem 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow);
border-left: 5px solid var(--tuva-green);
}
.title-block h1 {
font-size: 2rem;
font-weight: 700;
color: var(--text);
line-height: 1.3;
margin-bottom: 0.5rem;
}
.title-block .subtitle {
font-size: 1.05rem;
color: var(--text-secondary);
margin-bottom: 0;
}
/* Table of contents */
.toc {
background: var(--bg);
border-radius: 12px;
padding: 1.75rem 2rem;
margin-bottom: 2rem;
box-shadow: var(--shadow);
}
.toc h2 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--tuva-green);
margin-bottom: 1rem;
font-weight: 700;
}
.toc ol {
list-style: none;
counter-reset: toc-counter;
padding: 0;
}
.toc ol li {
counter-increment: toc-counter;
margin-bottom: 0.4rem;
}
.toc ol li::before {
content: counter(toc-counter) ".";
color: var(--tuva-green);
font-weight: 600;
margin-right: 0.5rem;
font-size: 0.9rem;
}
.toc a {
color: var(--text);
text-decoration: none;
font-size: 0.95rem;
transition: color 0.15s;
}
.toc a:hover { color: var(--tuva-green); }
/* Sections */
.section {
background: var(--bg);
border-radius: 12px;
padding: 2rem 2.5rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow);
}
h2 {
font-size: 1.5rem;
font-weight: 700;
color: var(--text);
margin-bottom: 1.25rem;
padding-bottom: 0.6rem;
border-bottom: 2px solid var(--tuva-green-light);
}
h2 .section-num {
color: var(--tuva-green);
margin-right: 0.25rem;
}
h3 {
font-size: 1.15rem;
font-weight: 600;
color: var(--text);
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
h3:first-child { margin-top: 0; }
h4 {
font-size: 1rem;
font-weight: 600;
color: var(--text-secondary);
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
p { margin-bottom: 1rem; }
/* Lists */
ul, ol {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
li { margin-bottom: 0.35rem; }
li strong { color: var(--text); }
/* Callout / highlight boxes */
.callout {
background: var(--tuva-green-light);
border-left: 4px solid var(--tuva-green);
border-radius: 0 8px 8px 0;
padding: 1rem 1.25rem;
margin: 1.25rem 0;
font-size: 0.95rem;
}
.callout p:last-child { margin-bottom: 0; }
.callout-neutral {
background: var(--bg-alt);
border-left: 4px solid var(--border);
border-radius: 0 8px 8px 0;
padding: 1rem 1.25rem;
margin: 1.25rem 0;
font-size: 0.95rem;
}
/* Option cards */
.option-card {
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.5rem 1.75rem;
margin: 1.25rem 0;
position: relative;
}
.option-card.recommended {
border-color: var(--tuva-green);
border-width: 2px;
}
.option-card.recommended::after {
content: "Recommended";
position: absolute;
top: -0.65rem;
right: 1.25rem;
background: var(--tuva-green);
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: 0.15rem 0.65rem;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.pros-cons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 1rem;
}
.pros, .cons {
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.9rem;
}
.pros { background: #e8f8ef; }
.cons { background: #fdf0ed; }
.pros strong { color: #1e8c4c; }
.cons strong { color: #c0392b; }
.pros ul, .cons ul { padding-left: 1.25rem; margin-bottom: 0; }
.pros li, .cons li { margin-bottom: 0.2rem; font-size: 0.88rem; }
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0 1.25rem;
font-size: 0.92rem;
}
thead th {
background: var(--tuva-green);
color: white;
font-weight: 600;
text-align: left;
padding: 0.65rem 0.9rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
thead th:first-child { border-radius: 8px 0 0 0; }
thead th:last-child { border-radius: 0 8px 0 0; }
tbody td {
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
tbody tr:nth-child(even) { background: var(--bg-alt); }
tbody tr:last-child td:first-child { border-radius: 0 0 0 8px; }
tbody tr:last-child td:last-child { border-radius: 0 0 8px 0; }
/* Code blocks */
code {
font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
font-size: 0.88em;
background: var(--code-bg);
padding: 0.15em 0.4em;
border-radius: 4px;
color: #c0392b;
}
pre {
background: #1a1a2e;
color: #e8e8f0;
padding: 1.25rem 1.5rem;
border-radius: 10px;
overflow-x: auto;
margin: 1rem 0 1.25rem;
font-size: 0.85rem;
line-height: 1.6;
}
pre code {
background: none;
padding: 0;
color: inherit;
font-size: inherit;
}
/* JSON syntax hints */
.json-key { color: #82aaff; }
.json-str { color: #c3e88d; }
.json-comment { color: #6a6a8a; }
/* Workflow steps */
.workflow-steps {
counter-reset: step-counter;
list-style: none;
padding-left: 0;
}
.workflow-steps li {
counter-increment: step-counter;
position: relative;
padding-left: 2.75rem;
margin-bottom: 0.75rem;
}
.workflow-steps li::before {
content: counter(step-counter);
position: absolute;
left: 0; top: 0.1rem;
width: 1.8rem; height: 1.8rem;
background: var(--tuva-green);
color: white;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 0.8rem; font-weight: 700;
}
/* Effort badge */
.effort-badge {
display: inline-block;
background: var(--tuva-green-light);
color: var(--tuva-green-dark);
font-size: 0.82rem;
font-weight: 600;
padding: 0.2rem 0.7rem;
border-radius: 20px;
margin-left: 0.5rem;
}
/* Phase timeline */
.phase-timeline {
position: relative;
padding-left: 2rem;
margin: 1.5rem 0;
}
.phase-timeline::before {
content: "";
position: absolute;
left: 0.55rem;
top: 0.5rem;
bottom: 0.5rem;
width: 3px;
background: linear-gradient(to bottom, var(--tuva-green), var(--tuva-green-light));
border-radius: 2px;
}
.phase-item {
position: relative;
margin-bottom: 1.5rem;
padding: 1rem 1.25rem;
background: var(--bg-alt);
border-radius: 10px;
border: 1px solid var(--border);
}
.phase-item::before {
content: "";
position: absolute;
left: -1.7rem; top: 1.25rem;
width: 12px; height: 12px;
background: var(--tuva-green);
border: 3px solid white;
border-radius: 50%;
box-shadow: 0 0 0 2px var(--tuva-green);
}
.phase-item h4 {
margin-top: 0;
color: var(--tuva-green-dark);
font-size: 1rem;
}
.phase-item p:last-child { margin-bottom: 0; }
.phase-time {
font-size: 0.8rem;
color: var(--text-secondary);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* Request card */
.request-card {
background: var(--tuva-green-light);
border-radius: 12px;
padding: 1.5rem 1.75rem;
margin: 1rem 0;
}
.request-card ol { margin-bottom: 0; }
.request-card li { margin-bottom: 0.6rem; }
/* File tree */
.file-tree {
font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
font-size: 0.85rem;
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem 1.5rem;
margin: 1rem 0;
line-height: 1.8;
}
.file-tree .dir { color: var(--tuva-green-dark); font-weight: 600; }
.file-tree .file { color: var(--text-secondary); }
.file-tree .desc { color: #8888a8; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 0.82rem; }
/* Separator */
hr {
border: none;
height: 1px;
background: var(--border);
margin: 1.5rem 0;
}
/* Footer */
.footer {
text-align: center;
color: var(--text-secondary);
font-size: 0.85rem;
padding: 2rem 0 1rem;
font-style: italic;
}
/* Print styles */
@media print {
body { background: white; }
.site-header { position: static; border-bottom-width: 2px; box-shadow: none; }
.section, .title-block, .toc { box-shadow: none; break-inside: avoid; }
.container { padding: 0; }
.option-card { break-inside: avoid; }
pre { white-space: pre-wrap; word-wrap: break-word; }
}
/* Responsive */
@media (max-width: 700px) {
.container { padding: 1rem; }
.section, .title-block, .toc { padding: 1.25rem 1.5rem; }
.pros-cons { grid-template-columns: 1fr; }
.site-header { padding: 1rem; }
.title-block h1 { font-size: 1.5rem; }
table { font-size: 0.82rem; }
thead th, tbody td { padding: 0.4rem 0.6rem; }
}
</style>
</head>
<body>
<!-- Header -->
<header class="site-header">
<div class="site-header-inner">
<div class="brand">
<div class="brand-logo">T</div>
<div class="brand-text">Tuva <span>Engineering</span></div>
</div>
<div class="header-date">March 2026</div>
</div>
</header>
<!-- Main Content -->
<div class="container">
<!-- Title -->
<div class="title-block">
<h1>Data-Driven Animations</h1>
<p class="subtitle">Scaling Plan &mdash; from POC to Content Team Self-Service</p>
</div>
<!-- Table of Contents -->
<nav class="toc">
<h2>Contents</h2>
<ol>
<li><a href="#poc">What We Built (POC)</a></li>
<li><a href="#options">How to Scale: Three Options</a></li>
<li><a href="#roadmap">Recommended Roadmap</a></li>
<li><a href="#requests">Content Team: How to Request an Animation</a></li>
<li><a href="#technical">Technical Details (for Developers)</a></li>
</ol>
</nav>
<!-- Section 1: What We Built -->
<section class="section" id="poc">
<h2><span class="section-num">1.</span> What We Built (POC)</h2>
<p>A system in <code>tuva-tools-viewer</code> where students click a data point in Tuva Data Tools and see a real-world animation of what that data represents. Two working demos:</p>
<ul>
<li><strong>Seasonal Shadow Length</strong> &mdash; Sun moves across the sky at the correct angle, casting the correct shadow length. Students see how sun angle and shadow length are connected in real life.</li>
<li><strong>Wolves Hunting Bison</strong> &mdash; Wolf pack &#x1F43A; surrounds a bison &#x1F9AC;. Pack size matches the data. Success rate bar shows hunting effectiveness. Fun verdict text changes (&ldquo;Strength in numbers!&rdquo;, &ldquo;Hunting is hard work!&rdquo;).</li>
</ul>
<div class="callout">
<p>Both use real data from tuvalabs.com, embedded in the app with pre-configured Tuva Jr. plot states.</p>
</div>
<h3>Architecture</h3>
<ul>
<li>Each animation is a <strong>template</strong>: a dataset + a React SVG scene component</li>
<li>Templates live in <code>client/src/components/data-animations/templates/</code></li>
<li>Adding a new one: create a folder, add <code>Template.ts</code> + <code>Scene.tsx</code>, register in <code>index.ts</code></li>
<li>Scene components receive the selected case&rsquo;s data as props and render SVG</li>
</ul>
</section>
<!-- Section 2: Three Options -->
<section class="section" id="options">
<h2><span class="section-num">2.</span> How to Scale: Three Options</h2>
<!-- Option A -->
<div class="option-card recommended">
<h3>Option A: LLM-Powered Generation <span class="effort-badge">30-60 min / animation</span></h3>
<p>Content team writes a natural language prompt. Claude generates the scene component. A developer reviews and integrates.</p>
<h4>Workflow</h4>
<ol class="workflow-steps">
<li>Content person identifies a dataset and writes a description: <em>&ldquo;Dataset about roller coaster physics with columns speed_mph, height_feet, g_force. Show a roller coaster car on a track. Height controls position on the hill, speed shows motion lines, g_force shows rider leaning back.&rdquo;</em></li>
<li>Developer feeds this to Claude along with the <code>AnimationTemplate</code> interface and 2 existing examples</li>
<li>Claude generates the Scene component (React + SVG + emoji)</li>
<li>Developer reviews, tweaks, drops into <code>templates/</code> folder</li>
<li>Add one line to <code>index.ts</code> &rarr; deployed</li>
</ol>
<div class="pros-cons">
<div class="pros">
<strong>Pros</strong>
<ul>
<li>Works today with no new tooling</li>
<li>High visual quality &mdash; LLMs are surprisingly good at SVG</li>
<li>Flexible &mdash; any visual concept</li>
<li>Content team drives the creative vision</li>
</ul>
</div>
<div class="cons">
<strong>Cons</strong>
<ul>
<li>Still needs a developer to review/integrate</li>
<li>Each animation is custom code</li>
<li>Quality varies, may need iteration</li>
</ul>
</div>
</div>
</div>
<!-- Option B -->
<div class="option-card">
<h3>Option B: Scene Template Library + Config <span class="effort-badge">5-15 min / animation</span></h3>
<p>Build a library of reusable scene types configurable via JSON. Content team picks a type and maps columns.</p>
<h4>Pre-built Scene Types to Consider</h4>
<table>
<thead>
<tr>
<th>Scene Type</th>
<th>Visual</th>
<th>Example Use</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>sky-angle</code></td>
<td>Sun/moon on arc + shadow</td>
<td>Shadow length, solar energy</td>
</tr>
<tr>
<td><code>count-vs-target</code></td>
<td>N entities surrounding target</td>
<td>Wolves/bison, predator/prey</td>
</tr>
<tr>
<td><code>scale-comparison</code></td>
<td>Object grows/shrinks with data</td>
<td>Plant growth, population</td>
</tr>
<tr>
<td><code>bar-race</code></td>
<td>Animated bars with icons</td>
<td>Comparing categories</td>
</tr>
<tr>
<td><code>timeline</code></td>
<td>Object moves along a path</td>
<td>Migration, seasons</td>
</tr>
<tr>
<td><code>before-after</code></td>
<td>Split scene, two states</td>
<td>Treatment effects</td>
</tr>
</tbody>
</table>
<h4>Example Configuration</h4>
<pre><code>{
<span class="json-key">"sceneType"</span>: <span class="json-str">"count-vs-target"</span>,
<span class="json-key">"datasetSlug"</span>: <span class="json-str">"wolves_hunting_bison"</span>,
<span class="json-key">"entityEmoji"</span>: <span class="json-str">"&#x1F43A;"</span>,
<span class="json-key">"targetEmoji"</span>: <span class="json-str">"&#x1F9AC;"</span>,
<span class="json-key">"countColumn"</span>: <span class="json-str">"wolf-hunting-group-size-attrib0"</span>,
<span class="json-key">"successColumn"</span>: <span class="json-str">"successful-captures-attrib2"</span>,
<span class="json-key">"background"</span>: <span class="json-str">"yellowstone"</span>
}</code></pre>
<div class="pros-cons">
<div class="pros">
<strong>Pros</strong>
<ul>
<li>No developer needed</li>
<li>Consistent quality</li>
<li>Fast</li>
</ul>
</div>
<div class="cons">
<strong>Cons</strong>
<ul>
<li>Limited to pre-built types</li>
<li>Upfront investment (~1-2 weeks per scene type)</li>
</ul>
</div>
</div>
</div>
<!-- Option C -->
<div class="option-card">
<h3>Option C: Visual Editor <span class="effort-badge">2-3 months to build</span></h3>
<p>Web-based drag-and-drop editor. Canvas with elements, property bindings (&ldquo;when column X changes, move this element&rdquo;), preview with real data.</p>
<div class="pros-cons">
<div class="pros">
<strong>Pros</strong>
<ul>
<li>Full self-serve</li>
<li>Unlimited creativity</li>
</ul>
</div>
<div class="cons">
<strong>Cons</strong>
<ul>
<li>Major engineering investment</li>
</ul>
</div>
</div>
</div>
</section>
<!-- Section 3: Roadmap -->
<section class="section" id="roadmap">
<h2><span class="section-num">3.</span> Recommended Roadmap</h2>
<div class="phase-timeline">
<div class="phase-item">
<span class="phase-time">Now &rarr; 1 month</span>
<h4>Phase 1: LLM Generation</h4>
<ul>
<li>Use Option A to build 5&ndash;10 more animations</li>
<li>Validate which datasets benefit most</li>
<li>Identify recurring visual patterns</li>
<li>Content team submits requests via a simple form/doc: dataset URL, column names, visual concept sketch</li>
</ul>
</div>
<div class="phase-item">
<span class="phase-time">1 &ndash; 3 months</span>
<h4>Phase 2: Template Library</h4>
<ul>
<li>Review the 10+ animations built in Phase 1</li>
<li>Extract 3&ndash;4 recurring scene types as configurable templates (Option B)</li>
<li>Content team self-serves for datasets matching those patterns</li>
<li>LLM generation continues for unique/custom scenes</li>
</ul>
</div>
<div class="phase-item">
<span class="phase-time">3 &ndash; 6 months (if demand warrants)</span>
<h4>Phase 3: Config UI</h4>
<ul>
<li>Build a simple web form (not full editor): select scene type, pick columns, choose emoji/colors, preview</li>
<li>Fraction of the cost of a full visual editor</li>
<li>Content team fully independent for supported scene types</li>
</ul>
</div>
</div>
</section>
<!-- Section 4: How to Request -->
<section class="section" id="requests">
<h2><span class="section-num">4.</span> Content Team: How to Request an Animation</h2>
<p>For Phase 1, submit requests with:</p>
<div class="request-card">
<ol>
<li><strong>Dataset URL</strong> on tuvalabs.com</li>
<li><strong>Which columns</strong> should drive the animation (and what they represent)</li>
<li><strong>Visual concept</strong> &mdash; 2&ndash;3 sentences describing what you want students to see. Be specific: <em>&ldquo;Show a penguin standing on an iceberg. As temperature increases, the iceberg shrinks. The penguin looks worried when temperature &gt; 5&deg;C.&rdquo;</em></li>
<li><strong>Key insight</strong> &mdash; what should students understand? <em>&ldquo;Higher temperatures = smaller icebergs = less penguin habitat&rdquo;</em></li>
</ol>
</div>
<div class="callout">
<p>The more specific the visual description, the better the generated animation will be.</p>
</div>
</section>
<!-- Section 5: Technical Details -->
<section class="section" id="technical">
<h2><span class="section-num">5.</span> Technical Details <span style="font-weight: 400; font-size: 0.85em; color: var(--text-secondary);">(for developers)</span></h2>
<p><strong>Repository:</strong> <code>tuva-tools-viewer</code> &rarr; <code>client/src/components/data-animations/</code></p>
<h3>Key Files</h3>
<div class="file-tree">
<span class="dir">data-animations/</span><br>
&nbsp;&nbsp;<span class="dir">templates/</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="file">types.ts</span> <span class="desc">&mdash; AnimationTemplate and SceneProps interfaces</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="file">index.ts</span> <span class="desc">&mdash; Template registry (add new templates here)</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="dir">shadow-length/</span> <span class="desc">&mdash; Reference for sky/angle scenes</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="dir">wolves-hunting/</span> <span class="desc">&mdash; Reference for count/entity scenes</span><br>
&nbsp;&nbsp;<span class="file">useDataToolsSelection.ts</span> <span class="desc">&mdash; Hook that polls Tuva Data Tools for selected case</span><br>
&nbsp;&nbsp;<span class="dir">pages/</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="file">DataAnimationsPage.tsx</span> <span class="desc">&mdash; Landing page</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;<span class="file">DataAnimationWorkspacePage.tsx</span> <span class="desc">&mdash; 2-panel workspace</span><br>
</div>
<h3>Data Integration</h3>
<p>Datasets are embedded as TypeScript constants (fetched from <code>GET /api/datasets/&lt;slug&gt;</code> with auth). Plot states from the API are included for pre-configured scatter plots.</p>
<h3>Selection Detection</h3>
<p>Polls <code>toolRef.current.actions.getPlotState()</code> every 200ms, reads <code>plotview.dataSet.selectedCases</code> array. The DynG mediator is internal to the UMD bundle and not accessible from the parent app.</p>
</section>
<!-- Footer -->
<div class="footer">
Generated from the Data-Driven Animations POC &mdash; Tuva Jr. &middot; March 2026
</div>
</div>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment