Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions doc/components/MdxComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import CodeBlock from './CodeBlock';
import MediaPlaceholder from './MediaPlaceholder';
import InstallCommand from './InstallCommand';
import ExampleNotebook from './ExampleNotebook';
import WidgetPreview from './WidgetPreview';

const MdxPre = ({ children }: { children?: React.ReactNode }) => {
if (!children || !React.isValidElement(children)) {
Expand All @@ -22,6 +23,7 @@ const mdxComponents = {
MediaPlaceholder,
InstallCommand,
ExampleNotebook,
WidgetPreview,
};

export default mdxComponents;
183 changes: 2 additions & 181 deletions doc/components/WidgetGallery.tsx
Original file line number Diff line number Diff line change
@@ -1,167 +1,21 @@
import React, { useRef, useEffect, useState } from 'react';
import { motion, useScroll, useTransform, useSpring, AnimatePresence } from 'framer-motion';
import React, { useRef } from 'react';
import { motion, useScroll, useTransform, useSpring } from 'framer-motion';
import DynamicWidget from './DynamicWidget';
import { EXAMPLES } from '../data/examples';
import { createWidgetModel } from '../utils/exampleDataLoader';
import { ArrowRight } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';

// import anime from 'animejs';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { materialLight } from 'react-syntax-highlighter/dist/esm/styles/prism';

// Context preview data for each widget - showing cells before and after
const WIDGET_CONTEXT = {
'tic-tac-toe': {
upperCell: {
type: 'code',
label: 'Train Model',
content: `# Train ML model on tic-tac-toe patterns
model = train_tictactoe_model(game_data)
print(f"Model accuracy: {model.score():.2%}")`
},
lowerCell: {
type: 'code',
label: 'AI Move',
content: `# Generate AI move based on board state
def make_ai_move(board_state):
prediction = model.predict(board_state)
return best_move(prediction)`
}
},
'weather-scatter': {
upperCell: {
type: 'code',
label: 'Load Data',
content: `# Load Seattle weather dataset
weather_df = pd.read_csv('seattle-weather.csv')
print(f"Loaded {len(weather_df)} days of data")`
},
lowerCell: {
type: 'code',
label: 'Linked Chart',
content: `# Create linked bar chart for selected weather
bars = vw.create(
"bar chart of weather conditions for selection",
vw.inputs(data, selected_indices=scatter.outputs.selected_indices)
)`
}
},
'solar-system': {
upperCell: {
type: 'code',
label: 'Extract PDF',
content: `# Extract planet data from PDF
planets_df = extract_from_pdf('planets.pdf')
print(planets_df[['name', 'distance', 'radius']].head())`
},
lowerCell: {
type: 'code',
label: 'Output State',
content: `# Access selected planet from widget
selected = solar_system.outputs.selected_planet.value
print(f"Currently viewing: {selected}")`
}
},
'hn-clone': {
upperCell: {
type: 'code',
label: 'Scrape Web',
content: `# Scrape Hacker News stories
stories = scrape_hackernews()
print(f"Found {len(stories)} stories")`
},
lowerCell: {
type: 'code',
label: 'Filter Data',
content: `# Filter high-scoring stories
top_stories = stories[stories.score > 100]
print(f"Top stories: {len(top_stories)}")`
}
}
};

const customStyle = {
padding: '1.25rem',
borderRadius: '0.75rem',
margin: 0,
fontSize: '13px',
lineHeight: '1.5',
fontFamily: 'Space Mono, JetBrains Mono, monospace',
};

// Context cell preview component
const ContextCell = ({ cell, position }: { cell: { type: string, label: string, content: string }, position: 'upper' | 'lower' }) => {
const gradientClass = position === 'upper'
? 'bg-gradient-to-t from-white to-white/10'
: 'bg-gradient-to-b from-white to-white/10';

return (
<motion.div
initial={{ opacity: 0, y: position === 'upper' ? -20 : 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: position === 'upper' ? -30 : 30 }}
transition={{
duration: 0.6,
ease: [0.4, 0.0, 0.2, 1]
}}
className={`absolute ${position === 'upper' ? 'bottom-full mb-6' : 'top-full mt-6'} left-0 right-0 pointer-events-none`}
>
<div className={`relative backdrop-blur-sm`}>
<div className="p-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-[8px] font-mono font-bold text-orange/60 uppercase tracking-widest">
{position === 'upper' ? '↑ Previous Cell' : '↓ Next Cell'}
</span>
<span className="text-[8px] font-mono text-slate/40 uppercase tracking-widest">
{cell.label}
</span>
</div>
<div className="text-material-bg relative overflow-hidden">
<div className="relative">
<pre className={`${gradientClass} p-4 rounded-lg text-xs font-mono overflow-x-auto`}>
<SyntaxHighlighter
language="python"
// style?: { [key: string]: React.CSSProperties } | undefined;
style={{
...materialLight,
'code[class*="language-"]': {
background: 'transparent',
},
'pre[class*="language-"]': {
background: 'transparent',
margin: 0,
},
}}
customStyle={customStyle}
PreTag="div"
CodeTag="code"
showLineNumbers={false}
>
{cell.content}
</SyntaxHighlighter>
</pre>
</div>
</div>
</div>
</div>
</motion.div>
);
};

// Fixed: Added key to props type to allow assignment when mapping
const GalleryItem = ({
example,
index,
mode,
model,
showContext = false
}: {
example: typeof EXAMPLES[0],
index: number,
mode: 'horizontal' | 'grid',
model?: any,
showContext?: boolean,
key?: React.Key
}) => {
const navigate = useNavigate();
Expand All @@ -170,8 +24,6 @@ const GalleryItem = ({
navigate(`/gallery?focus=${example.id}`);
};

const contextData = WIDGET_CONTEXT[example.id as keyof typeof WIDGET_CONTEXT];

return (
<motion.div
initial={{ opacity: 0, y: 20 }}
Expand All @@ -184,16 +36,6 @@ const GalleryItem = ({
${mode === 'horizontal' ? 'min-w-[280px] sm:min-w-[360px] lg:min-w-[450px]' : 'w-full'}
`}
>
{/* Context previews (only in horizontal mode) */}
{mode === 'horizontal' && showContext && contextData && (
<AnimatePresence mode="wait">
<React.Fragment key={example.id}>
<ContextCell cell={contextData.upperCell} position="upper" />
<ContextCell cell={contextData.lowerCell} position="lower" />
</React.Fragment>
</AnimatePresence>
)}

<div className="h-[200px] sm:h-[240px] lg:h-[280px] bg-bone border-2 border-slate/5 rounded-lg overflow-hidden relative shadow-inner group-hover:border-orange/20 transition-colors">
<div className="absolute inset-0 bg-grid-pattern opacity-[0.05] pointer-events-none" />
<div className="h-full w-full overflow-hidden">
Expand All @@ -205,15 +47,9 @@ const GalleryItem = ({
dataType={example.dataType}
/>
</div>
{/* Decorative Overlay */}
<div className="absolute top-2 right-2 px-2 py-1 bg-white/80 backdrop-blur rounded text-[9px] font-mono border border-slate/5 text-slate/40 uppercase tracking-widest">Live Runtime</div>
</div>

<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono font-bold text-orange uppercase bg-orange/10 px-2 py-0.5 rounded tracking-widest">Component</span>
<span className="text-[10px] font-mono text-slate/30 uppercase tracking-widest">ID: VW-00{index + 1}</span>
</div>
<h3 className="text-xl font-display font-bold group-hover:text-orange transition-colors">{example.label}</h3>
<p className="font-mono text-xs text-slate/60 line-clamp-2 leading-relaxed italic border-l-2 border-slate/10 pl-3">"{example.prompt}"</p>
</div>
Expand All @@ -232,9 +68,6 @@ const WidgetGallery = ({ mode }: WidgetGalleryProps) => {
offset: ["start start", "end end"]
});

// Track which widget is currently centered for context preview
const [centeredIndex, setCenteredIndex] = useState(0);

// Shared models for cross-widget reactivity (using dataUrl as key)
const modelsRef = useRef<Map<string, any>>(new Map());

Expand All @@ -252,17 +85,6 @@ const WidgetGallery = ({ mode }: WidgetGalleryProps) => {
const x = useTransform(scrollYProgress, [0, 1], ["0%", "-65%"]);
const springX = useSpring(x, { stiffness: 100, damping: 20 });

// Track centered widget based on scroll progress
useEffect(() => {
const unsubscribe = scrollYProgress.on("change", (latest) => {
const featuredCount = featuredExamples.length;
// Calculate which item is centered based on scroll progress
const newIndex = Math.min(Math.round(latest * featuredCount * 0.8), featuredCount - 1);
setCenteredIndex(newIndex);
});
return () => unsubscribe();
}, [scrollYProgress]);

// Filter for featured widgets in horizontal mode
const featuredExamples = mode === 'horizontal'
? EXAMPLES.filter(ex => ex.categories.includes('Featured')).slice(0, 4)
Expand All @@ -280,7 +102,6 @@ const WidgetGallery = ({ mode }: WidgetGalleryProps) => {
index={i}
mode="horizontal"
model={getModelForExample(ex)}
showContext={i === centeredIndex}
/>
))}

Expand Down
128 changes: 128 additions & 0 deletions doc/components/WidgetPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { loadDataFile, createWidgetModel, isDataCached, getCachedData } from '../utils/exampleDataLoader';
import { resolvePublicUrl } from '../utils/resolvePublicUrl';
import { transformWidgetModule } from '../utils/transformWidgetModule';

interface WidgetPreviewProps {
/** Path to a .vw bundle or .js module in /public (e.g. "/widgets/my_chart.vw") */
src: string;
/** Optional data file URL (e.g. "/testdata/seattle-weather.csv") */
dataUrl?: string;
/** Data type for dataUrl */
dataType?: 'csv' | 'json';
/** Preview height in pixels */
height?: number;
}

/**
* Renders a widget preview above code blocks in docs.
* Supports both .vw bundles (JSON with `code` field) and raw .js modules.
*/
export default function WidgetPreview({
src,
dataUrl,
dataType = 'csv',
height = 400,
}: WidgetPreviewProps) {
const [Widget, setWidget] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const [dataLoaded, setDataLoaded] = useState(!dataUrl);
const blobUrlRef = useRef<string | null>(null);

const widgetModel = useMemo(() => createWidgetModel([]), []);

// Load data if needed
useEffect(() => {
if (!dataUrl) return;

const resolved = resolvePublicUrl(dataUrl);
if (isDataCached(resolved)) {
widgetModel.set('data', getCachedData(resolved));
setDataLoaded(true);
return;
}

let cancelled = false;
loadDataFile(resolved, dataType)
.then((data) => {
if (!cancelled && data?.length) {
widgetModel.set('data', data);
setDataLoaded(true);
}
})
.catch((e) => {
console.error('WidgetPreview: failed to load data', e);
if (!cancelled) setDataLoaded(true); // render anyway
});
return () => { cancelled = true; };
}, [dataUrl, dataType, widgetModel]);

// Load widget code
useEffect(() => {
let cancelled = false;

async function load() {
try {
const resolved = resolvePublicUrl(src);
const response = await fetch(resolved);
if (!response.ok) throw new Error(`Failed to fetch ${src}: ${response.statusText}`);

let code: string;
const text = await response.text();

// .vw files are JSON bundles with a `code` field
if (src.endsWith('.vw')) {
const bundle = JSON.parse(text);
code = bundle.code || '';
if (!code) throw new Error('.vw bundle has no code');
} else {
code = text;
}

const compiled = await transformWidgetModule(code);
const blob = new Blob([compiled], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
blobUrlRef.current = blobUrl;

const mod = await import(/* @vite-ignore */ blobUrl);
const fn = mod?.default ?? mod;
if (typeof fn !== 'function') throw new Error('Widget module has no default export');
if (!cancelled) setWidget(() => fn);
} catch (e: any) {
console.error('WidgetPreview:', e);
if (!cancelled) setError(e.message || 'Failed to load widget');
}
}

load();
return () => {
cancelled = true;
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, [src]);

return (
<div
className="bg-white border-2 border-slate rounded-lg overflow-hidden my-4 shadow-hard-sm"
style={{ height }}
>
{error ? (
<div className="flex items-center justify-center h-full p-4 text-red-500 font-mono text-xs">
{error}
</div>
) : Widget && dataLoaded ? (
<Widget model={widgetModel} React={React} />
) : (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-2">
<div className="w-5 h-5 border-2 border-orange border-t-transparent rounded-full animate-spin" />
<span className="text-xs text-slate/40 font-mono">Loading widget…</span>
</div>
</div>
)}
</div>
);
}
Loading