From 4c0fa0de4e32e678ed578bd21e399cd25b76586d Mon Sep 17 00:00:00 2001 From: Dylan Date: Thu, 29 Jan 2026 14:07:28 -0500 Subject: [PATCH 1/3] added previews for the versions --- doc/components/MdxComponents.tsx | 2 + doc/components/WidgetGallery.tsx | 183 +----------- doc/components/WidgetPreview.tsx | 128 +++++++++ doc/content/composability.mdx | 2 + doc/content/config.mdx | 4 +- doc/content/create.mdx | 2 + doc/content/installation.mdx | 4 + doc/content/reactivity.mdx | 6 + doc/content/theming.mdx | 7 + doc/pages/GalleryPage.tsx | 50 ++-- doc/public/widgets/bar_chart_revenue.js | 186 +++++++++++++ doc/public/widgets/scatter_actions.js | 183 ++++++++++++ doc/public/widgets/scatter_brush.js | 165 +++++++++++ doc/public/widgets/scatter_brush_linked.js | 262 ++++++++++++++++++ doc/public/widgets/scatter_themed_ft.js | 160 +++++++++++ doc/public/widgets/scatter_tooltips_legend.js | 204 ++++++++++++++ 16 files changed, 1334 insertions(+), 214 deletions(-) create mode 100644 doc/components/WidgetPreview.tsx create mode 100644 doc/public/widgets/bar_chart_revenue.js create mode 100644 doc/public/widgets/scatter_actions.js create mode 100644 doc/public/widgets/scatter_brush.js create mode 100644 doc/public/widgets/scatter_brush_linked.js create mode 100644 doc/public/widgets/scatter_themed_ft.js create mode 100644 doc/public/widgets/scatter_tooltips_legend.js diff --git a/doc/components/MdxComponents.tsx b/doc/components/MdxComponents.tsx index 3890331..f2a7387 100644 --- a/doc/components/MdxComponents.tsx +++ b/doc/components/MdxComponents.tsx @@ -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)) { @@ -22,6 +23,7 @@ const mdxComponents = { MediaPlaceholder, InstallCommand, ExampleNotebook, + WidgetPreview, }; export default mdxComponents; diff --git a/doc/components/WidgetGallery.tsx b/doc/components/WidgetGallery.tsx index a09d0dd..93acb15 100644 --- a/doc/components/WidgetGallery.tsx +++ b/doc/components/WidgetGallery.tsx @@ -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 ( - -
-
-
- - {position === 'upper' ? '↑ Previous Cell' : '↓ Next Cell'} - - - {cell.label} - -
-
-
-
-                
-                  {cell.content}
-                
-              
-
-
-
-
-
- ); -}; - -// 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(); @@ -170,8 +24,6 @@ const GalleryItem = ({ navigate(`/gallery?focus=${example.id}`); }; - const contextData = WIDGET_CONTEXT[example.id as keyof typeof WIDGET_CONTEXT]; - return ( - {/* Context previews (only in horizontal mode) */} - {mode === 'horizontal' && showContext && contextData && ( - - - - - - - )} -
@@ -205,15 +47,9 @@ const GalleryItem = ({ dataType={example.dataType} />
- {/* Decorative Overlay */} -
Live Runtime
-
- Component - ID: VW-00{index + 1} -

{example.label}

"{example.prompt}"

@@ -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>(new Map()); @@ -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) @@ -280,7 +102,6 @@ const WidgetGallery = ({ mode }: WidgetGalleryProps) => { index={i} mode="horizontal" model={getModelForExample(ex)} - showContext={i === centeredIndex} /> ))} diff --git a/doc/components/WidgetPreview.tsx b/doc/components/WidgetPreview.tsx new file mode 100644 index 0000000..7817fdc --- /dev/null +++ b/doc/components/WidgetPreview.tsx @@ -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(null); + const [error, setError] = useState(null); + const [dataLoaded, setDataLoaded] = useState(!dataUrl); + const blobUrlRef = useRef(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 ( +
+ {error ? ( +
+ {error} +
+ ) : Widget && dataLoaded ? ( + + ) : ( +
+
+
+ Loading widget… +
+
+ )} +
+ ); +} diff --git a/doc/content/composability.mdx b/doc/content/composability.mdx index 79b2965..c7dfc6f 100644 --- a/doc/content/composability.mdx +++ b/doc/content/composability.mdx @@ -8,6 +8,8 @@ versioning, and avoiding re-generation. ## Save a widget + + ```python widget = vw.create("scatter plot with brush selection", df) widget.save("my_widget.vw") diff --git a/doc/content/config.mdx b/doc/content/config.mdx index 06bc19c..9619943 100644 --- a/doc/content/config.mdx +++ b/doc/content/config.mdx @@ -20,8 +20,8 @@ vw.config(execution="approve") Get an OpenRouter API key: https://openrouter.ai/ -Need help setting API keys in Jupyter? See this guide: -https://docs.google.com/document/d/e/2PACX-1vST8sEHdo90NsTdTFNjLi27YFT-81u2WQa7--qr0u4yk2aByE6Q5WIj-p8JYueEING5-fNdFNu2Aa3t/pub +Need help setting API keys in Jupyter? See [this guide](https://docs.google.com/document/d/e/2PACX-1vST8sEHdo90NsTdTFNjLi27YFT-81u2WQa7--qr0u4yk2aByE6Q5WIj-p8JYueEING5-fNdFNu2Aa3t/pub). + ```bash export OPENROUTER_API_KEY='your-key' diff --git a/doc/content/create.mdx b/doc/content/create.mdx index ab6effe..5eb7d38 100644 --- a/doc/content/create.mdx +++ b/doc/content/create.mdx @@ -5,6 +5,8 @@ export const frontmatter = { Create widgets from natural language prompts and data sources. + + ```python import vibe_widget as vw diff --git a/doc/content/installation.mdx b/doc/content/installation.mdx index 5bc0856..65a841c 100644 --- a/doc/content/installation.mdx +++ b/doc/content/installation.mdx @@ -35,6 +35,8 @@ export OPENROUTER_API_KEY="your-key" $env:OPENROUTER_API_KEY="your-key" ``` + + ```python import pandas as pd import vibe_widget as vw @@ -49,6 +51,8 @@ If you want the key to persist across sessions, add the `export` line to your sh ## Quick start + + ```python import pandas as pd import vibe_widget as vw diff --git a/doc/content/reactivity.mdx b/doc/content/reactivity.mdx index d6ac345..3bdd27d 100644 --- a/doc/content/reactivity.mdx +++ b/doc/content/reactivity.mdx @@ -27,6 +27,8 @@ widget.inputs.data = new_df # resets the data in the widget You can also wire one widget's output to another widget's input: + + ```python # Slider selection flows into chart's highlight chart = vw.create( @@ -62,6 +64,8 @@ widget.outputs.selected_indices.observe(on_selection) Actions are one-time commands from Python to the widget. Use them for behavior that should happen once, not persist as state. + + ```python widget = vw.create( "interactive scatter plot", @@ -95,6 +99,8 @@ behavior by firing once and not lingering. The real power of this model shows up when you connect widgets: + + ```python scatter = vw.create( "scatter plot with brush selection tool", diff --git a/doc/content/theming.mdx b/doc/content/theming.mdx index a80cc0b..cc9526e 100644 --- a/doc/content/theming.mdx +++ b/doc/content/theming.mdx @@ -38,8 +38,15 @@ vw.create("...", df, theme="financial_times") Edits reuse the existing theme by default, so your visual language stays consistent as you refine behavior or layout. + + ```python v1 = vw.create("basic scatter", df, theme="financial_times") +``` + + + +```python v2 = v1.edit("add hover tooltips and a right-side legend") ``` diff --git a/doc/pages/GalleryPage.tsx b/doc/pages/GalleryPage.tsx index 5b179aa..5799331 100644 --- a/doc/pages/GalleryPage.tsx +++ b/doc/pages/GalleryPage.tsx @@ -278,7 +278,7 @@ const GalleryPage = () => { initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4 sm:gap-6 auto-rows-[220px] sm:auto-rows-[260px] lg:auto-rows-[300px]" + className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" > {filteredExamples.map((example, index) => ( { }; const GalleryCard = ({ example, index, model, onOpen }: { example: typeof EXAMPLES[0]; index: number; model: any; onOpen: () => void }) => { - const sizeClasses = { - small: 'md:col-span-2 md:row-span-1', - medium: 'md:col-span-2 md:row-span-2', - large: 'md:col-span-4 md:row-span-2', - }; - const hasNotebook = !!NOTEBOOK_MAP[example.id]; return ( @@ -394,9 +388,8 @@ const GalleryCard = ({ example, index, model, onOpen }: { example: typeof EXAMPL exit={{ opacity: 0, scale: 0.9 }} transition={{ duration: 0.5, delay: index * 0.05, type: "spring", stiffness: 200, damping: 25 }} className={` - relative group bg-white border-2 border-slate rounded-2xl overflow-hidden shadow-hard hover:shadow-hard-lg transition-all + relative group bg-white border-2 border-slate rounded-2xl overflow-hidden shadow-hard hover:shadow-hard-lg transition-all flex flex-col ${hasNotebook ? 'cursor-pointer' : 'cursor-default'} - ${sizeClasses[example.size as keyof typeof sizeClasses] || 'md:col-span-1 md:row-span-1'} `} onClick={hasNotebook ? onOpen : undefined} onKeyDown={(event) => { @@ -410,7 +403,7 @@ const GalleryCard = ({ example, index, model, onOpen }: { example: typeof EXAMPL tabIndex={hasNotebook ? 0 : -1} > {/* Preview Area */} -
+
{example.gifUrl ? ( {example.label} ) : ( @@ -426,26 +419,24 @@ const GalleryCard = ({ example, index, model, onOpen }: { example: typeof EXAMPL )} {/* Hover Overlay for Navigation */} -
-
- {hasNotebook && ( - - )} + {hasNotebook && ( +
+
-
+ )}
- {/* Content Overlay - Non-interactive part */} -
+ {/* Content */} +
{example.categories.map((cat: string) => ( @@ -453,16 +444,13 @@ const GalleryCard = ({ example, index, model, onOpen }: { example: typeof EXAMPL ))}
-

+

{example.label}

"{example.prompt}"

- - {/* Decorative Corner */} -
); }; diff --git a/doc/public/widgets/bar_chart_revenue.js b/doc/public/widgets/bar_chart_revenue.js new file mode 100644 index 0000000..fa66eee --- /dev/null +++ b/doc/public/widgets/bar_chart_revenue.js @@ -0,0 +1,186 @@ +export default function BarChartRevenue({ model, React }) { + const { useState, useEffect, useRef, useCallback } = React; + + const data = [ + { region: 'North America', revenue: 4200 }, + { region: 'Europe', revenue: 3100 }, + { region: 'Asia Pacific', revenue: 2800 }, + { region: 'Latin America', revenue: 1400 }, + { region: 'Middle East', revenue: 950 }, + ]; + + const palette = ['#f97316', '#4a7c8a', '#c4a35a', '#a0522d', '#6b7b8d']; + + const [hovered, setHovered] = useState(null); + const [animProgress, setAnimProgress] = useState(0); + const animRef = useRef(null); + + useEffect(() => { + const start = performance.now(); + const duration = 900; + function tick(now) { + const t = Math.min((now - start) / duration, 1); + const ease = 1 - Math.pow(1 - t, 3); + setAnimProgress(ease); + if (t < 1) animRef.current = requestAnimationFrame(tick); + } + animRef.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(animRef.current); + }, []); + + const maxRevenue = Math.max(...data.map(d => d.revenue)); + const margin = { top: 32, right: 24, bottom: 64, left: 100 }; + const width = 560; + const height = 340; + const plotW = width - margin.left - margin.right; + const plotH = height - margin.top - margin.bottom; + const barH = Math.min(36, (plotH / data.length) - 8); + const gap = (plotH - barH * data.length) / (data.length + 1); + + const ticks = [0, 1000, 2000, 3000, 4000, 5000]; + + const fmt = v => v >= 1000 ? `$${(v / 1000).toFixed(v % 1000 === 0 ? 0 : 1)}k` : `$${v}`; + + return React.createElement('div', { + style: { + width: '100%', height: '100%', + background: '#f7f0e6', + display: 'flex', alignItems: 'center', justifyContent: 'center', + fontFamily: "'JetBrains Mono', 'SF Mono', 'Fira Code', monospace", + position: 'relative', overflow: 'hidden', + } + }, + React.createElement('svg', { + viewBox: `0 0 ${width} ${height}`, + style: { width: '100%', maxWidth: width, height: 'auto' }, + }, + // Title + React.createElement('text', { + x: margin.left, y: 20, + style: { fontSize: 14, fontWeight: 700, fill: '#1a1a1a', fontFamily: "'Space Grotesk', sans-serif" } + }, 'Revenue by Region'), + + // Grid lines + ...ticks.map(tick => + React.createElement('line', { + key: `grid-${tick}`, + x1: margin.left + (tick / 5000) * plotW, + x2: margin.left + (tick / 5000) * plotW, + y1: margin.top, + y2: margin.top + plotH, + stroke: '#1a1a1a', + strokeOpacity: 0.08, + strokeWidth: 1, + strokeDasharray: tick === 0 ? 'none' : '3,3', + }) + ), + + // X axis ticks + ...ticks.map(tick => + React.createElement('text', { + key: `xtick-${tick}`, + x: margin.left + (tick / 5000) * plotW, + y: margin.top + plotH + 20, + textAnchor: 'middle', + style: { fontSize: 10, fill: '#1a1a1a', opacity: 0.5, fontFamily: 'inherit' } + }, fmt(tick)) + ), + + // Axis line + React.createElement('line', { + x1: margin.left, x2: margin.left, + y1: margin.top, y2: margin.top + plotH, + stroke: '#1a1a1a', strokeWidth: 2, + }), + + // Bars + ...data.map((d, i) => { + const y = margin.top + gap + i * (barH + gap); + const barWidth = (d.revenue / 5000) * plotW * animProgress; + const isHov = hovered === i; + + return React.createElement('g', { key: d.region }, + // Region label + React.createElement('text', { + x: margin.left - 8, + y: y + barH / 2 + 1, + textAnchor: 'end', + dominantBaseline: 'middle', + style: { + fontSize: 11, fill: '#1a1a1a', + fontWeight: isHov ? 700 : 400, + fontFamily: 'inherit', + transition: 'font-weight 0.15s', + }, + }, d.region), + + // Bar shadow + React.createElement('rect', { + x: margin.left + 3, + y: y + 3, + width: barWidth, + height: barH, + rx: 3, + fill: '#1a1a1a', + opacity: 0.12, + }), + + // Bar + React.createElement('rect', { + x: margin.left, + y: y, + width: barWidth, + height: barH, + rx: 3, + fill: palette[i], + stroke: isHov ? '#1a1a1a' : 'none', + strokeWidth: isHov ? 2 : 0, + style: { cursor: 'pointer', transition: 'stroke 0.15s, filter 0.15s', filter: isHov ? 'brightness(1.08)' : 'none' }, + onMouseEnter: () => setHovered(i), + onMouseLeave: () => setHovered(null), + }), + + // Value label + animProgress > 0.5 && React.createElement('text', { + x: margin.left + barWidth + 8, + y: y + barH / 2 + 1, + dominantBaseline: 'middle', + style: { + fontSize: 11, + fill: '#1a1a1a', + fontWeight: 600, + fontFamily: 'inherit', + opacity: Math.min(1, (animProgress - 0.5) * 4), + } + }, fmt(d.revenue)), + ); + }), + + // X axis label + React.createElement('text', { + x: margin.left + plotW / 2, + y: height - 8, + textAnchor: 'middle', + style: { fontSize: 10, fill: '#1a1a1a', opacity: 0.4, fontFamily: 'inherit', textTransform: 'uppercase', letterSpacing: 1.5 }, + }, 'Revenue (USD)'), + ), + + // Tooltip + hovered !== null && React.createElement('div', { + style: { + position: 'absolute', top: 12, right: 16, + background: '#f7f0e6', + border: '2px solid #1a1a1a', + borderRadius: 6, + padding: '8px 12px', + boxShadow: '3px 3px 0 0 #1a1a1a', + fontFamily: 'inherit', + fontSize: 11, + zIndex: 10, + } + }, + React.createElement('div', { style: { fontWeight: 700, marginBottom: 2 } }, data[hovered].region), + React.createElement('div', { style: { color: palette[hovered] } }, fmt(data[hovered].revenue)), + ), + ); +} diff --git a/doc/public/widgets/scatter_actions.js b/doc/public/widgets/scatter_actions.js new file mode 100644 index 0000000..d847699 --- /dev/null +++ b/doc/public/widgets/scatter_actions.js @@ -0,0 +1,183 @@ +export default function ScatterActions({ model, React }) { + const { useState, useEffect, useMemo, useCallback } = React; + + const basePoints = useMemo(() => { + const seed = 77; + const rng = (() => { let s = seed; return () => { s = (s * 16807 + 0) % 2147483647; return s / 2147483647; }; })(); + return Array.from({ length: 40 }, (_, i) => ({ + x: 10 + rng() * 80, + y: 10 + rng() * 80, + id: i, + })); + }, []); + + const [zoom, setZoom] = useState({ x: 0, y: 0, scale: 1 }); + const [hovered, setHovered] = useState(null); + const [animProgress, setAnimProgress] = useState(0); + const [resetFlash, setResetFlash] = useState(false); + + useEffect(() => { + const start = performance.now(); + let raf; + function tick(now) { + const t = Math.min((now - start) / 700, 1); + setAnimProgress(1 - Math.pow(1 - t, 3)); + if (t < 1) raf = requestAnimationFrame(tick); + } + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, []); + + const m = { top: 36, right: 20, bottom: 44, left: 48 }; + const w = 480, h = 340; + const pw = w - m.left - m.right; + const ph = h - m.top - m.bottom; + + const zoomIn = useCallback((cx, cy) => { + setZoom(z => ({ + x: cx - (cx - z.x) / 1.4, + y: cy - (cy - z.y) / 1.4, + scale: Math.min(z.scale * 1.4, 5), + })); + }, []); + + const resetView = useCallback(() => { + setZoom({ x: 0, y: 0, scale: 1 }); + setResetFlash(true); + setTimeout(() => setResetFlash(false), 400); + }, []); + + const sx = v => m.left + ((v / 100) * pw * zoom.scale) + zoom.x; + const sy = v => m.top + ph - ((v / 100) * ph * zoom.scale) - zoom.y; + + return React.createElement('div', { + style: { + width: '100%', height: '100%', + background: '#f7f0e6', + display: 'flex', alignItems: 'center', justifyContent: 'center', + fontFamily: "'JetBrains Mono', 'SF Mono', monospace", + position: 'relative', + } + }, + React.createElement('svg', { + viewBox: `0 0 ${w} ${h}`, + style: { width: '100%', maxWidth: w, height: 'auto' }, + }, + // Title + React.createElement('text', { + x: m.left, y: 22, + style: { fontSize: 13, fontWeight: 700, fill: '#1a1a1a', fontFamily: "'Space Grotesk', sans-serif" }, + }, 'Interactive Scatter — Click to Zoom'), + + // Clip path for plot area + React.createElement('defs', null, + React.createElement('clipPath', { id: 'plot-clip' }, + React.createElement('rect', { x: m.left, y: m.top, width: pw, height: ph }), + ), + ), + + // Background flash on reset + resetFlash && React.createElement('rect', { + x: m.left, y: m.top, width: pw, height: ph, + fill: '#f97316', fillOpacity: 0.06, + style: { transition: 'fill-opacity 0.4s' }, + }), + + // Grid (clipped) + React.createElement('g', { clipPath: 'url(#plot-clip)' }, + ...[0, 25, 50, 75, 100].flatMap(v => [ + React.createElement('line', { + key: `gx-${v}`, x1: sx(v), x2: sx(v), y1: m.top, y2: m.top + ph, + stroke: '#1a1a1a', strokeOpacity: 0.06, strokeDasharray: '2,3', + }), + React.createElement('line', { + key: `gy-${v}`, x1: m.left, x2: m.left + pw, y1: sy(v), y2: sy(v), + stroke: '#1a1a1a', strokeOpacity: 0.06, strokeDasharray: '2,3', + }), + ]), + ), + + // Axes + React.createElement('line', { x1: m.left, x2: m.left + pw, y1: m.top + ph, y2: m.top + ph, stroke: '#1a1a1a', strokeWidth: 2 }), + React.createElement('line', { x1: m.left, x2: m.left, y1: m.top, y2: m.top + ph, stroke: '#1a1a1a', strokeWidth: 2 }), + + // Tick labels + ...[0, 25, 50, 75, 100].flatMap(v => [ + React.createElement('text', { key: `xt-${v}`, x: sx(v), y: m.top + ph + 16, textAnchor: 'middle', style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.4 } }, v), + React.createElement('text', { key: `yt-${v}`, x: m.left - 6, y: sy(v) + 3, textAnchor: 'end', style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.4 } }, v), + ]), + + // Points (clipped) + React.createElement('g', { clipPath: 'url(#plot-clip)' }, + ...basePoints.map((p, i) => + React.createElement('circle', { + key: p.id, + cx: sx(p.x), + cy: sy(p.y) + (1 - animProgress) * 25, + r: hovered === i ? 7 : 5, + fill: hovered === i ? '#f97316' : '#4a7c8a', + fillOpacity: hovered === i ? 0.95 : 0.7 * animProgress, + stroke: hovered === i ? '#1a1a1a' : '#4a7c8a', + strokeWidth: hovered === i ? 2 : 0.5, + strokeOpacity: 0.3, + style: { cursor: 'pointer', transition: 'cx 0.4s, cy 0.4s, r 0.12s, fill 0.15s' }, + onMouseEnter: () => setHovered(i), + onMouseLeave: () => setHovered(null), + onClick: () => zoomIn(sx(p.x), sy(p.y)), + }) + ), + ), + + // Zoom level indicator + zoom.scale > 1 && React.createElement('g', null, + React.createElement('rect', { + x: w - m.right - 54, y: m.top + 2, width: 48, height: 18, rx: 9, + fill: '#f97316', fillOpacity: 0.12, stroke: '#f97316', strokeWidth: 1, + }), + React.createElement('text', { + x: w - m.right - 30, y: m.top + 14, textAnchor: 'middle', + style: { fontSize: 9, fill: '#f97316', fontWeight: 700 }, + }, `${zoom.scale.toFixed(1)}x`), + ), + ), + + // Action button — Reset View + React.createElement('button', { + onClick: resetView, + style: { + position: 'absolute', + bottom: 16, right: 16, + background: zoom.scale > 1 ? '#f97316' : '#f7f0e6', + color: zoom.scale > 1 ? '#fff' : '#1a1a1a', + border: '2px solid #1a1a1a', + borderRadius: 6, + padding: '6px 14px', + fontSize: 11, + fontWeight: 700, + fontFamily: "'JetBrains Mono', monospace", + cursor: 'pointer', + boxShadow: '2px 2px 0 0 #1a1a1a', + transition: 'background 0.2s, color 0.2s, transform 0.1s', + }, + onMouseDown: (e) => { e.currentTarget.style.transform = 'translate(1px, 1px)'; e.currentTarget.style.boxShadow = '1px 1px 0 0 #1a1a1a'; }, + onMouseUp: (e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = '2px 2px 0 0 #1a1a1a'; }, + }, 'Reset View'), + + // Tooltip + hovered !== null && React.createElement('div', { + style: { + position: 'absolute', top: 12, right: 16, + background: '#f7f0e6', + border: '2px solid #1a1a1a', + borderRadius: 6, + padding: '6px 10px', + boxShadow: '3px 3px 0 0 #1a1a1a', + fontSize: 11, + } + }, + React.createElement('div', { style: { opacity: 0.5 } }, `Point ${basePoints[hovered].id}`), + React.createElement('div', null, `x: ${basePoints[hovered].x.toFixed(1)} y: ${basePoints[hovered].y.toFixed(1)}`), + React.createElement('div', { style: { color: '#f97316', fontSize: 9, marginTop: 2 } }, 'Click to zoom'), + ), + ); +} diff --git a/doc/public/widgets/scatter_brush.js b/doc/public/widgets/scatter_brush.js new file mode 100644 index 0000000..b8f2101 --- /dev/null +++ b/doc/public/widgets/scatter_brush.js @@ -0,0 +1,165 @@ +export default function ScatterBrush({ model, React }) { + const { useState, useEffect, useRef, useCallback, useMemo } = React; + + const points = useMemo(() => { + const seed = 17; + const rng = (() => { let s = seed; return () => { s = (s * 16807 + 0) % 2147483647; return s / 2147483647; }; })(); + return Array.from({ length: 50 }, (_, i) => ({ + x: 10 + rng() * 80, + y: 10 + rng() * 80, + size: 3 + rng() * 4, + id: i, + })); + }, []); + + const [brush, setBrush] = useState(null); + const [brushing, setBrushing] = useState(false); + const [brushStart, setBrushStart] = useState(null); + const [animProgress, setAnimProgress] = useState(0); + const svgRef = useRef(null); + + useEffect(() => { + const start = performance.now(); + let raf; + function tick(now) { + const t = Math.min((now - start) / 800, 1); + setAnimProgress(1 - Math.pow(1 - t, 3)); + if (t < 1) raf = requestAnimationFrame(tick); + } + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, []); + + const m = { top: 32, right: 20, bottom: 40, left: 44 }; + const w = 480, h = 340; + const pw = w - m.left - m.right; + const ph = h - m.top - m.bottom; + + const sx = v => m.left + (v / 100) * pw; + const sy = v => m.top + ph - (v / 100) * ph; + + const selected = useMemo(() => { + if (!brush) return new Set(); + const [x1, y1, x2, y2] = [ + Math.min(brush.x1, brush.x2), Math.min(brush.y1, brush.y2), + Math.max(brush.x1, brush.x2), Math.max(brush.y1, brush.y2), + ]; + return new Set(points.filter(p => { + const px = sx(p.x), py = sy(p.y); + return px >= x1 && px <= x2 && py >= y1 && py <= y2; + }).map(p => p.id)); + }, [brush, points]); + + const getSvgPoint = useCallback((e) => { + const svg = svgRef.current; + if (!svg) return null; + const rect = svg.getBoundingClientRect(); + return { + x: ((e.clientX - rect.left) / rect.width) * w, + y: ((e.clientY - rect.top) / rect.height) * h, + }; + }, []); + + const onMouseDown = useCallback((e) => { + const pt = getSvgPoint(e); + if (!pt) return; + setBrushing(true); + setBrushStart(pt); + setBrush({ x1: pt.x, y1: pt.y, x2: pt.x, y2: pt.y }); + }, [getSvgPoint]); + + const onMouseMove = useCallback((e) => { + if (!brushing || !brushStart) return; + const pt = getSvgPoint(e); + if (!pt) return; + setBrush({ x1: brushStart.x, y1: brushStart.y, x2: pt.x, y2: pt.y }); + }, [brushing, brushStart, getSvgPoint]); + + const onMouseUp = useCallback(() => { + setBrushing(false); + if (brush && Math.abs(brush.x2 - brush.x1) < 4 && Math.abs(brush.y2 - brush.y1) < 4) { + setBrush(null); + } + }, [brush]); + + return React.createElement('div', { + style: { + width: '100%', height: '100%', + background: '#f7f0e6', + display: 'flex', alignItems: 'center', justifyContent: 'center', + fontFamily: "'JetBrains Mono', 'SF Mono', monospace", + } + }, + React.createElement('svg', { + ref: svgRef, + viewBox: `0 0 ${w} ${h}`, + style: { width: '100%', maxWidth: w, height: 'auto', cursor: 'crosshair', userSelect: 'none' }, + onMouseDown, onMouseMove, onMouseUp, + onMouseLeave: () => { if (brushing) onMouseUp(); }, + }, + React.createElement('text', { + x: m.left, y: 18, + style: { fontSize: 13, fontWeight: 700, fill: '#1a1a1a', fontFamily: "'Space Grotesk', sans-serif" }, + }, 'Scatter Plot — Brush Selection'), + + // Selection count badge + selected.size > 0 && React.createElement('g', null, + React.createElement('rect', { + x: w - m.right - 80, y: 6, width: 74, height: 20, rx: 10, + fill: '#f97316', fillOpacity: 0.12, stroke: '#f97316', strokeWidth: 1, + }), + React.createElement('text', { + x: w - m.right - 43, y: 19, textAnchor: 'middle', + style: { fontSize: 9, fill: '#f97316', fontWeight: 700 }, + }, `${selected.size} selected`), + ), + + // Grid + ...[0, 25, 50, 75, 100].flatMap(v => [ + React.createElement('line', { + key: `gx-${v}`, x1: sx(v), x2: sx(v), y1: m.top, y2: m.top + ph, + stroke: '#1a1a1a', strokeOpacity: 0.06, strokeDasharray: '2,3', + }), + React.createElement('line', { + key: `gy-${v}`, x1: m.left, x2: m.left + pw, y1: sy(v), y2: sy(v), + stroke: '#1a1a1a', strokeOpacity: 0.06, strokeDasharray: '2,3', + }), + ]), + + // Axes + React.createElement('line', { x1: m.left, x2: m.left + pw, y1: m.top + ph, y2: m.top + ph, stroke: '#1a1a1a', strokeWidth: 2 }), + React.createElement('line', { x1: m.left, x2: m.left, y1: m.top, y2: m.top + ph, stroke: '#1a1a1a', strokeWidth: 2 }), + + // Tick labels + ...[0, 25, 50, 75, 100].flatMap(v => [ + React.createElement('text', { key: `xt-${v}`, x: sx(v), y: m.top + ph + 16, textAnchor: 'middle', style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.4 } }, v), + React.createElement('text', { key: `yt-${v}`, x: m.left - 6, y: sy(v) + 3, textAnchor: 'end', style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.4 } }, v), + ]), + + // Points + ...points.map(p => { + const sel = selected.size > 0; + const isSel = selected.has(p.id); + return React.createElement('circle', { + key: p.id, + cx: sx(p.x), + cy: sy(p.y) + (1 - animProgress) * 30, + r: sel ? (isSel ? p.size + 1 : p.size - 1) : p.size, + fill: isSel ? '#f97316' : '#4a7c8a', + fillOpacity: sel ? (isSel ? 0.85 : 0.12) : 0.65 * animProgress, + stroke: isSel ? '#1a1a1a' : 'none', + strokeWidth: 1.5, + style: { transition: 'fill-opacity 0.2s, r 0.15s, fill 0.2s, cy 0.4s', pointerEvents: 'none' }, + }); + }), + + // Brush rect + brush && React.createElement('rect', { + x: Math.min(brush.x1, brush.x2), y: Math.min(brush.y1, brush.y2), + width: Math.abs(brush.x2 - brush.x1), height: Math.abs(brush.y2 - brush.y1), + fill: '#f97316', fillOpacity: 0.07, + stroke: '#f97316', strokeWidth: 1.5, strokeDasharray: '4,2', rx: 2, + }), + ), + ); +} diff --git a/doc/public/widgets/scatter_brush_linked.js b/doc/public/widgets/scatter_brush_linked.js new file mode 100644 index 0000000..b5479aa --- /dev/null +++ b/doc/public/widgets/scatter_brush_linked.js @@ -0,0 +1,262 @@ +export default function ScatterBrushLinked({ model, React }) { + const { useState, useEffect, useRef, useCallback, useMemo } = React; + + // Generate sample data + const points = useMemo(() => { + const seed = 42; + const rng = (() => { let s = seed; return () => { s = (s * 16807 + 0) % 2147483647; return s / 2147483647; }; })(); + const cats = ['Group A', 'Group B', 'Group C']; + const colors = ['#f97316', '#4a7c8a', '#c4a35a']; + return Array.from({ length: 60 }, (_, i) => { + const cat = Math.floor(rng() * 3); + const cx = [35, 60, 75][cat]; + const cy = [65, 40, 70][cat]; + return { + x: cx + (rng() - 0.5) * 40, + y: cy + (rng() - 0.5) * 35, + cat: cats[cat], + color: colors[cat], + id: i, + }; + }); + }, []); + + const [brush, setBrush] = useState(null); + const [brushing, setBrushing] = useState(false); + const [brushStart, setBrushStart] = useState(null); + const [animProgress, setAnimProgress] = useState(0); + const svgRef = useRef(null); + + useEffect(() => { + const start = performance.now(); + const duration = 700; + let raf; + function tick(now) { + const t = Math.min((now - start) / duration, 1); + setAnimProgress(1 - Math.pow(1 - t, 3)); + if (t < 1) raf = requestAnimationFrame(tick); + } + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, []); + + const scatterM = { top: 28, right: 12, bottom: 36, left: 40 }; + const histM = { top: 28, right: 12, bottom: 36, left: 40 }; + const sw = 300, sh = 280; + const hw = 220, hh = 280; + const spw = sw - scatterM.left - scatterM.right; + const sph = sh - scatterM.top - scatterM.bottom; + + const sx = v => scatterM.left + (v / 100) * spw; + const sy = v => scatterM.top + sph - (v / 100) * sph; + + const selected = useMemo(() => { + if (!brush) return new Set(); + const [x1, y1, x2, y2] = [ + Math.min(brush.x1, brush.x2), Math.min(brush.y1, brush.y2), + Math.max(brush.x1, brush.x2), Math.max(brush.y1, brush.y2), + ]; + return new Set(points.filter(p => { + const px = sx(p.x), py = sy(p.y); + return px >= x1 && px <= x2 && py >= y1 && py <= y2; + }).map(p => p.id)); + }, [brush, points]); + + const activePoints = selected.size > 0 ? points.filter(p => selected.has(p.id)) : points; + + // Histogram bins for y values + const bins = useMemo(() => { + const nBins = 8; + const binSize = 100 / nBins; + const b = Array.from({ length: nBins }, (_, i) => ({ + lo: i * binSize, hi: (i + 1) * binSize, count: 0, + })); + activePoints.forEach(p => { + const idx = Math.min(Math.floor(p.y / binSize), nBins - 1); + b[idx].count++; + }); + return b; + }, [activePoints]); + + const maxBin = Math.max(...bins.map(b => b.count), 1); + const hpw = hw - histM.left - histM.right; + const hph = hh - histM.top - histM.bottom; + + const getSvgPoint = useCallback((e) => { + const svg = svgRef.current; + if (!svg) return null; + const rect = svg.getBoundingClientRect(); + return { + x: ((e.clientX - rect.left) / rect.width) * sw, + y: ((e.clientY - rect.top) / rect.height) * sh, + }; + }, []); + + const onMouseDown = useCallback((e) => { + const pt = getSvgPoint(e); + if (!pt) return; + setBrushing(true); + setBrushStart(pt); + setBrush({ x1: pt.x, y1: pt.y, x2: pt.x, y2: pt.y }); + }, [getSvgPoint]); + + const onMouseMove = useCallback((e) => { + if (!brushing || !brushStart) return; + const pt = getSvgPoint(e); + if (!pt) return; + setBrush({ x1: brushStart.x, y1: brushStart.y, x2: pt.x, y2: pt.y }); + }, [brushing, brushStart, getSvgPoint]); + + const onMouseUp = useCallback(() => { + setBrushing(false); + if (brush && Math.abs(brush.x2 - brush.x1) < 4 && Math.abs(brush.y2 - brush.y1) < 4) { + setBrush(null); + } + }, [brush]); + + return React.createElement('div', { + style: { + width: '100%', height: '100%', + background: '#f7f0e6', + display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, + fontFamily: "'JetBrains Mono', 'SF Mono', monospace", + padding: '8px 16px', + boxSizing: 'border-box', + } + }, + // Scatter plot + React.createElement('svg', { + ref: svgRef, + viewBox: `0 0 ${sw} ${sh}`, + style: { flex: '1 1 58%', maxWidth: sw, height: 'auto', cursor: 'crosshair', userSelect: 'none' }, + onMouseDown, + onMouseMove, + onMouseUp, + onMouseLeave: () => { if (brushing) onMouseUp(); }, + }, + React.createElement('text', { + x: scatterM.left, y: 16, + style: { fontSize: 12, fontWeight: 700, fill: '#1a1a1a', fontFamily: "'Space Grotesk', sans-serif" }, + }, 'Scatter — drag to select'), + + // Grid + ...[0, 25, 50, 75, 100].map(v => + React.createElement('line', { + key: `gx-${v}`, + x1: sx(v), x2: sx(v), y1: scatterM.top, y2: scatterM.top + sph, + stroke: '#1a1a1a', strokeOpacity: 0.06, strokeDasharray: '2,3', + }) + ), + ...[0, 25, 50, 75, 100].map(v => + React.createElement('line', { + key: `gy-${v}`, + x1: scatterM.left, x2: scatterM.left + spw, y1: sy(v), y2: sy(v), + stroke: '#1a1a1a', strokeOpacity: 0.06, strokeDasharray: '2,3', + }) + ), + + // Axes + React.createElement('line', { x1: scatterM.left, x2: scatterM.left + spw, y1: scatterM.top + sph, y2: scatterM.top + sph, stroke: '#1a1a1a', strokeWidth: 2 }), + React.createElement('line', { x1: scatterM.left, x2: scatterM.left, y1: scatterM.top, y2: scatterM.top + sph, stroke: '#1a1a1a', strokeWidth: 2 }), + + // Axis labels + ...[0, 50, 100].map(v => + React.createElement('text', { key: `xl-${v}`, x: sx(v), y: scatterM.top + sph + 16, textAnchor: 'middle', style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.4 } }, v) + ), + ...[0, 50, 100].map(v => + React.createElement('text', { key: `yl-${v}`, x: scatterM.left - 6, y: sy(v) + 3, textAnchor: 'end', style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.4 } }, v) + ), + + // Points + ...points.map(p => + React.createElement('circle', { + key: p.id, + cx: sx(p.x), + cy: sy(p.y) + (1 - animProgress) * 20, + r: selected.size > 0 ? (selected.has(p.id) ? 5 : 3) : 4.5, + fill: p.color, + fillOpacity: selected.size > 0 ? (selected.has(p.id) ? 0.9 : 0.15) : 0.75 * animProgress, + stroke: selected.has(p.id) ? '#1a1a1a' : 'none', + strokeWidth: 1.5, + style: { transition: 'fill-opacity 0.2s, r 0.2s, cy 0.3s', pointerEvents: 'none' }, + }) + ), + + // Brush rect + brush && React.createElement('rect', { + x: Math.min(brush.x1, brush.x2), + y: Math.min(brush.y1, brush.y2), + width: Math.abs(brush.x2 - brush.x1), + height: Math.abs(brush.y2 - brush.y1), + fill: '#f97316', + fillOpacity: 0.08, + stroke: '#f97316', + strokeWidth: 1.5, + strokeDasharray: '4,2', + rx: 2, + }), + + // X axis title + React.createElement('text', { + x: scatterM.left + spw / 2, y: sh - 4, textAnchor: 'middle', + style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.35, textTransform: 'uppercase', letterSpacing: 1.2 }, + }, 'x value'), + ), + + // Histogram + React.createElement('svg', { + viewBox: `0 0 ${hw} ${hh}`, + style: { flex: '1 1 38%', maxWidth: hw, height: 'auto' }, + }, + React.createElement('text', { + x: histM.left, y: 16, + style: { fontSize: 12, fontWeight: 700, fill: '#1a1a1a', fontFamily: "'Space Grotesk', sans-serif" }, + }, selected.size > 0 ? `Distribution (${selected.size} selected)` : 'Distribution'), + + // Axes + React.createElement('line', { x1: histM.left, x2: histM.left + hpw, y1: histM.top + hph, y2: histM.top + hph, stroke: '#1a1a1a', strokeWidth: 2 }), + React.createElement('line', { x1: histM.left, x2: histM.left, y1: histM.top, y2: histM.top + hph, stroke: '#1a1a1a', strokeWidth: 2 }), + + // Bars + ...bins.map((b, i) => { + const barH = (b.count / Math.max(maxBin, 1)) * hph * animProgress; + const barW = (hpw / bins.length) - 3; + const bx = histM.left + i * (hpw / bins.length) + 1.5; + const by = histM.top + hph - barH; + return React.createElement('g', { key: i }, + React.createElement('rect', { + x: bx + 2, y: by + 2, width: barW, height: barH, + rx: 2, fill: '#1a1a1a', opacity: 0.08, + }), + React.createElement('rect', { + x: bx, y: by, width: barW, height: barH, + rx: 2, + fill: selected.size > 0 ? '#f97316' : '#4a7c8a', + style: { transition: 'height 0.3s, y 0.3s, fill 0.3s' }, + }), + b.count > 0 && React.createElement('text', { + x: bx + barW / 2, y: by - 4, textAnchor: 'middle', + style: { fontSize: 8, fill: '#1a1a1a', opacity: 0.5 }, + }, b.count), + ); + }), + + // Bin labels + ...bins.filter((_, i) => i % 2 === 0).map((b, idx) => { + const i = idx * 2; + return React.createElement('text', { + key: `bl-${i}`, + x: histM.left + i * (hpw / bins.length) + (hpw / bins.length) / 2, + y: histM.top + hph + 14, + textAnchor: 'middle', + style: { fontSize: 8, fill: '#1a1a1a', opacity: 0.4 }, + }, Math.round(b.lo)); + }), + + React.createElement('text', { + x: histM.left + hpw / 2, y: hh - 4, textAnchor: 'middle', + style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.35, textTransform: 'uppercase', letterSpacing: 1.2 }, + }, 'y value'), + ), + ); +} diff --git a/doc/public/widgets/scatter_themed_ft.js b/doc/public/widgets/scatter_themed_ft.js new file mode 100644 index 0000000..90adb54 --- /dev/null +++ b/doc/public/widgets/scatter_themed_ft.js @@ -0,0 +1,160 @@ +export default function ScatterThemedFT({ model, React }) { + const { useState, useEffect, useMemo } = React; + + // Financial Times inspired: salmon pink, muted palette, clean axes + const ftPalette = ['#eb5a5a', '#1a6c8b', '#e8a83e', '#6b7b8d']; + const categories = ['Equities', 'Bonds', 'Commodities', 'FX']; + + const points = useMemo(() => { + const seed = 31; + const rng = (() => { let s = seed; return () => { s = (s * 16807 + 0) % 2147483647; return s / 2147483647; }; })(); + return Array.from({ length: 48 }, (_, i) => { + const cat = Math.floor(rng() * 4); + const cx = [25, 55, 70, 40][cat]; + const cy = [60, 35, 65, 45][cat]; + return { + x: cx + (rng() - 0.5) * 35, + y: cy + (rng() - 0.5) * 30, + cat, + id: i, + }; + }); + }, []); + + const [hovered, setHovered] = useState(null); + const [animProgress, setAnimProgress] = useState(0); + + useEffect(() => { + const start = performance.now(); + let raf; + function tick(now) { + const t = Math.min((now - start) / 800, 1); + setAnimProgress(1 - Math.pow(1 - t, 3)); + if (t < 1) raf = requestAnimationFrame(tick); + } + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, []); + + const m = { top: 36, right: 120, bottom: 44, left: 48 }; + const w = 520, h = 340; + const pw = w - m.left - m.right; + const ph = h - m.top - m.bottom; + + const sx = v => m.left + (v / 100) * pw; + const sy = v => m.top + ph - (v / 100) * ph; + + const hovPt = hovered !== null ? points[hovered] : null; + + return React.createElement('div', { + style: { + width: '100%', height: '100%', + background: '#fff1e5', + display: 'flex', alignItems: 'center', justifyContent: 'center', + fontFamily: "'Georgia', 'Times New Roman', serif", + position: 'relative', + } + }, + React.createElement('svg', { + viewBox: `0 0 ${w} ${h}`, + style: { width: '100%', maxWidth: w, height: 'auto' }, + }, + // FT-style top rule + React.createElement('line', { x1: 0, x2: w, y1: 0, y2: 0, stroke: '#1a1a1a', strokeWidth: 4 }), + + // Title + React.createElement('text', { + x: m.left, y: 24, + style: { fontSize: 15, fontWeight: 700, fill: '#1a1a1a', fontFamily: "'Georgia', serif" }, + }, 'Asset Performance Overview'), + + // Grid lines (FT uses light horizontal lines, no vertical) + ...[0, 25, 50, 75, 100].map(v => + React.createElement('line', { + key: `g-${v}`, + x1: m.left, x2: m.left + pw, + y1: sy(v), y2: sy(v), + stroke: '#1a1a1a', strokeOpacity: v === 0 ? 0.2 : 0.07, + strokeWidth: v === 0 ? 1.5 : 1, + }) + ), + + // Y axis labels + ...[0, 25, 50, 75, 100].map(v => + React.createElement('text', { + key: `yl-${v}`, x: m.left - 8, y: sy(v) + 3, textAnchor: 'end', + style: { fontSize: 10, fill: '#66605c', fontFamily: "'JetBrains Mono', monospace" }, + }, v) + ), + + // X axis labels + ...[0, 25, 50, 75, 100].map(v => + React.createElement('text', { + key: `xl-${v}`, x: sx(v), y: m.top + ph + 18, textAnchor: 'middle', + style: { fontSize: 10, fill: '#66605c', fontFamily: "'JetBrains Mono', monospace" }, + }, v) + ), + + // Points + ...points.map((p, i) => + React.createElement('circle', { + key: p.id, + cx: sx(p.x), + cy: sy(p.y) + (1 - animProgress) * 25, + r: hovered === i ? 6.5 : 4.5, + fill: ftPalette[p.cat], + fillOpacity: hovered === i ? 1 : 0.7 * animProgress, + stroke: hovered === i ? '#1a1a1a' : 'none', + strokeWidth: 1.5, + style: { cursor: 'pointer', transition: 'r 0.12s, fill-opacity 0.15s, cy 0.4s' }, + onMouseEnter: () => setHovered(i), + onMouseLeave: () => setHovered(null), + }) + ), + + // Legend (FT-style, right side) + ...categories.map((cat, i) => + React.createElement('g', { key: cat }, + React.createElement('circle', { + cx: m.left + pw + 20, + cy: m.top + 10 + i * 22, + r: 5, fill: ftPalette[i], + }), + React.createElement('text', { + x: m.left + pw + 30, + y: m.top + 14 + i * 22, + style: { fontSize: 11, fill: '#1a1a1a', fontFamily: "'Georgia', serif" }, + }, cat), + ) + ), + + // Axis titles + React.createElement('text', { + x: m.left + pw / 2, y: h - 4, textAnchor: 'middle', + style: { fontSize: 10, fill: '#66605c', fontStyle: 'italic' }, + }, 'Risk Score'), + React.createElement('text', { + x: 14, y: m.top + ph / 2, textAnchor: 'middle', + style: { fontSize: 10, fill: '#66605c', fontStyle: 'italic' }, + transform: `rotate(-90, 14, ${m.top + ph / 2})`, + }, 'Return (%)'), + ), + + // Tooltip + hovPt && React.createElement('div', { + style: { + position: 'absolute', top: 48, right: 20, + background: '#fff1e5', + border: '1px solid #1a1a1a', + borderTop: '3px solid ' + ftPalette[hovPt.cat], + padding: '6px 10px', + fontSize: 11, + fontFamily: "'JetBrains Mono', monospace", + boxShadow: '2px 2px 0 0 rgba(26,26,26,0.1)', + } + }, + React.createElement('div', { style: { fontWeight: 700, fontFamily: "'Georgia', serif", marginBottom: 2 } }, categories[hovPt.cat]), + React.createElement('div', { style: { color: '#66605c' } }, `Risk: ${hovPt.x.toFixed(1)} Return: ${hovPt.y.toFixed(1)}%`), + ), + ); +} diff --git a/doc/public/widgets/scatter_tooltips_legend.js b/doc/public/widgets/scatter_tooltips_legend.js new file mode 100644 index 0000000..0bf6652 --- /dev/null +++ b/doc/public/widgets/scatter_tooltips_legend.js @@ -0,0 +1,204 @@ +export default function ScatterTooltipsLegend({ model, React }) { + const { useState, useEffect, useMemo } = React; + + const categories = ['Revenue', 'Growth', 'Margin']; + const palette = ['#f97316', '#4a7c8a', '#c4a35a']; + + const points = useMemo(() => { + const seed = 53; + const rng = (() => { let s = seed; return () => { s = (s * 16807 + 0) % 2147483647; return s / 2147483647; }; })(); + return Array.from({ length: 45 }, (_, i) => { + const cat = Math.floor(rng() * 3); + const cx = [30, 55, 75][cat]; + const cy = [55, 70, 40][cat]; + return { + x: cx + (rng() - 0.5) * 40, + y: cy + (rng() - 0.5) * 35, + value: Math.round(20 + rng() * 80), + cat, + id: i, + }; + }); + }, []); + + const [hovered, setHovered] = useState(null); + const [hovPos, setHovPos] = useState({ x: 0, y: 0 }); + const [animProgress, setAnimProgress] = useState(0); + const [activeCats, setActiveCats] = useState(new Set([0, 1, 2])); + + useEffect(() => { + const start = performance.now(); + let raf; + function tick(now) { + const t = Math.min((now - start) / 800, 1); + setAnimProgress(1 - Math.pow(1 - t, 3)); + if (t < 1) raf = requestAnimationFrame(tick); + } + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, []); + + const m = { top: 32, right: 20, bottom: 44, left: 48 }; + const w = 500, h = 340; + const pw = w - m.left - m.right; + const ph = h - m.top - m.bottom; + + const sx = v => m.left + (v / 100) * pw; + const sy = v => m.top + ph - (v / 100) * ph; + + const toggleCat = (cat) => { + setActiveCats(prev => { + const next = new Set(prev); + if (next.has(cat)) { if (next.size > 1) next.delete(cat); } + else next.add(cat); + return next; + }); + }; + + const hovPt = hovered !== null ? points[hovered] : null; + + return React.createElement('div', { + style: { + width: '100%', height: '100%', + background: '#f7f0e6', + display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', + fontFamily: "'JetBrains Mono', 'SF Mono', monospace", + position: 'relative', + padding: '8px 0', + } + }, + React.createElement('svg', { + viewBox: `0 0 ${w} ${h}`, + style: { width: '100%', maxWidth: w, height: 'auto' }, + }, + // Title + React.createElement('text', { + x: m.left, y: 18, + style: { fontSize: 13, fontWeight: 700, fill: '#1a1a1a', fontFamily: "'Space Grotesk', sans-serif" }, + }, 'Performance Metrics'), + + // Subtle grid + ...[0, 25, 50, 75, 100].flatMap(v => [ + React.createElement('line', { + key: `gx-${v}`, x1: sx(v), x2: sx(v), y1: m.top, y2: m.top + ph, + stroke: '#1a1a1a', strokeOpacity: 0.05, strokeDasharray: '2,4', + }), + React.createElement('line', { + key: `gy-${v}`, x1: m.left, x2: m.left + pw, y1: sy(v), y2: sy(v), + stroke: '#1a1a1a', strokeOpacity: 0.05, strokeDasharray: '2,4', + }), + ]), + + // Axes + React.createElement('line', { x1: m.left, x2: m.left + pw, y1: m.top + ph, y2: m.top + ph, stroke: '#1a1a1a', strokeWidth: 2 }), + React.createElement('line', { x1: m.left, x2: m.left, y1: m.top, y2: m.top + ph, stroke: '#1a1a1a', strokeWidth: 2 }), + + // Tick labels + ...[0, 25, 50, 75, 100].flatMap(v => [ + React.createElement('text', { key: `xt-${v}`, x: sx(v), y: m.top + ph + 16, textAnchor: 'middle', style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.4 } }, v), + React.createElement('text', { key: `yt-${v}`, x: m.left - 6, y: sy(v) + 3, textAnchor: 'end', style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.4 } }, v), + ]), + + // Points + ...points.map((p, i) => { + const active = activeCats.has(p.cat); + return React.createElement('circle', { + key: p.id, + cx: sx(p.x), + cy: sy(p.y) + (1 - animProgress) * 20, + r: hovered === i ? 7 : 4.5, + fill: palette[p.cat], + fillOpacity: active ? (hovered === i ? 0.95 : 0.65 * animProgress) : 0.08, + stroke: hovered === i ? '#1a1a1a' : 'none', + strokeWidth: 2, + style: { cursor: 'pointer', transition: 'r 0.12s, fill-opacity 0.25s, cy 0.4s' }, + onMouseEnter: (e) => { setHovered(i); setHovPos({ x: e.clientX, y: e.clientY }); }, + onMouseLeave: () => setHovered(null), + }); + }), + + // Axis titles + React.createElement('text', { + x: m.left + pw / 2, y: h - 4, textAnchor: 'middle', + style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.35, textTransform: 'uppercase', letterSpacing: 1.2 }, + }, 'Efficiency'), + React.createElement('text', { + x: 14, y: m.top + ph / 2, textAnchor: 'middle', + style: { fontSize: 9, fill: '#1a1a1a', opacity: 0.35, textTransform: 'uppercase', letterSpacing: 1.2 }, + transform: `rotate(-90, 14, ${m.top + ph / 2})`, + }, 'Score'), + ), + + // Legend bar (interactive, below chart) + React.createElement('div', { + style: { + display: 'flex', gap: 12, marginTop: 4, + background: '#f7f0e6', + border: '2px solid #1a1a1a', + borderRadius: 8, + padding: '5px 14px', + boxShadow: '2px 2px 0 0 #1a1a1a', + } + }, + ...categories.map((cat, i) => + React.createElement('div', { + key: cat, + onClick: () => toggleCat(i), + style: { + display: 'flex', alignItems: 'center', gap: 5, + cursor: 'pointer', userSelect: 'none', + opacity: activeCats.has(i) ? 1 : 0.3, + transition: 'opacity 0.2s', + } + }, + React.createElement('div', { + style: { + width: 10, height: 10, borderRadius: 2, + background: palette[i], + border: '1.5px solid #1a1a1a', + } + }), + React.createElement('span', { + style: { fontSize: 10, fontWeight: 600, color: '#1a1a1a' }, + }, cat), + ) + ), + ), + + // Floating tooltip + hovPt && activeCats.has(hovPt.cat) && React.createElement('div', { + style: { + position: 'absolute', + top: 40, right: 16, + background: '#f7f0e6', + border: '2px solid #1a1a1a', + borderRadius: 6, + padding: '8px 12px', + boxShadow: '3px 3px 0 0 #1a1a1a', + fontSize: 11, + zIndex: 10, + minWidth: 120, + } + }, + React.createElement('div', { + style: { + fontWeight: 700, marginBottom: 4, paddingBottom: 4, + borderBottom: `2px solid ${palette[hovPt.cat]}`, + fontFamily: "'Space Grotesk', sans-serif", + } + }, categories[hovPt.cat]), + React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12 } }, + React.createElement('span', { style: { opacity: 0.5 } }, 'Efficiency'), + React.createElement('span', { style: { fontWeight: 600 } }, hovPt.x.toFixed(1)), + ), + React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12 } }, + React.createElement('span', { style: { opacity: 0.5 } }, 'Score'), + React.createElement('span', { style: { fontWeight: 600 } }, hovPt.y.toFixed(1)), + ), + React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12 } }, + React.createElement('span', { style: { opacity: 0.5 } }, 'Value'), + React.createElement('span', { style: { fontWeight: 600, color: palette[hovPt.cat] } }, hovPt.value), + ), + ), + ); +} From a2e7046c092df138c6c4fde1d8ee4e5a523762cf Mon Sep 17 00:00:00 2001 From: Dylan Date: Thu, 29 Jan 2026 14:20:28 -0500 Subject: [PATCH 2/3] rm live examples --- doc/data/docsManifest.js | 48 +++++++--------------------------------- doc/pages/DocsPage.tsx | 8 ------- 2 files changed, 8 insertions(+), 48 deletions(-) diff --git a/doc/data/docsManifest.js b/doc/data/docsManifest.js index 2f5813a..d5f5dc4 100644 --- a/doc/data/docsManifest.js +++ b/doc/data/docsManifest.js @@ -67,34 +67,6 @@ export const DOC_PAGES = [ section: 'Core Concepts', source: 'theming.mdx', }, - { - id: 'examples-cross-widget', - path: '/docs/examples/cross-widget', - label: 'Cross-Widget Demo', - section: 'Live Examples', - source: 'examples/cross-widget.mdx', - }, - { - id: 'examples-tictactoe', - path: '/docs/examples/tictactoe', - label: 'Tic-Tac-Toe AI', - section: 'Live Examples', - source: 'examples/tictactoe.mdx', - }, - { - id: 'examples-pdf-web', - path: '/docs/examples/pdf-web', - label: 'PDF & Web Data', - section: 'Live Examples', - source: 'examples/pdf-web.mdx', - }, - { - id: 'examples-edit', - path: '/docs/examples/edit', - label: 'Edit Example', - section: 'Live Examples', - source: 'examples/edit.mdx', - }, { id: 'widgetarium', path: '/docs/widgetarium', @@ -120,18 +92,14 @@ export const DOC_SECTIONS = [ })), }, { - title: 'Live Examples', - links: DOC_PAGES.filter((page) => page.section === 'Live Examples').map((page) => ({ - label: page.label, - path: page.path, - })), - }, - { - title: 'Ecosystem', - links: DOC_PAGES.filter((page) => page.section === 'Ecosystem').map((page) => ({ - label: page.label, - path: page.path, - })), + title: 'Explore', + links: [ + { label: 'Example Gallery', path: '/gallery' }, + ...DOC_PAGES.filter((page) => page.section === 'Ecosystem').map((page) => ({ + label: page.label, + path: page.path, + })), + ], }, ]; diff --git a/doc/pages/DocsPage.tsx b/doc/pages/DocsPage.tsx index d83ef6d..81141fc 100644 --- a/doc/pages/DocsPage.tsx +++ b/doc/pages/DocsPage.tsx @@ -9,10 +9,6 @@ const AuditPage = React.lazy(() => import('./docs/AuditPage')); const ReactivityPage = React.lazy(() => import('./docs/ReactivityPage')); const ComposabilityPage = React.lazy(() => import('./docs/ComposabilityPage')); const WidgetariumPage = React.lazy(() => import('./docs/WidgetariumPage')); -const CrossWidgetExamplePage = React.lazy(() => import('./docs/examples/CrossWidgetExamplePage')); -const TicTacToeExamplePage = React.lazy(() => import('./docs/examples/TicTacToeExamplePage')); -const PdfWebExamplePage = React.lazy(() => import('./docs/examples/PdfWebExamplePage')); -const EditExamplePage = React.lazy(() => import('./docs/examples/EditExamplePage')); const ComingSoonPage = React.lazy(() => import('./docs/ComingSoonPage')); import { DOC_SECTIONS } from '../data/docsManifest'; @@ -64,10 +60,6 @@ const DocsPage = () => { } /> } /> } /> - } /> - } /> - } /> - } /> } /> From 5459dc1edc8efb0119edf38c692a296a75779e0a Mon Sep 17 00:00:00 2001 From: Dylan Date: Thu, 29 Jan 2026 14:22:04 -0500 Subject: [PATCH 3/3] added static stub --- doc/scripts/build-static-docs.mjs | 5 +---- doc/scripts/mdxStaticComponents.js | 8 ++++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/doc/scripts/build-static-docs.mjs b/doc/scripts/build-static-docs.mjs index ba82a56..4ed4e0a 100644 --- a/doc/scripts/build-static-docs.mjs +++ b/doc/scripts/build-static-docs.mjs @@ -163,10 +163,7 @@ const buildLlmsTxt = () => { `- ${DOC_SITE.securitySummary}`, '', 'Examples:', - '- /docs/examples/cross-widget', - '- /docs/examples/tictactoe', - '- /docs/examples/pdf-web', - '- /docs/examples/edit', + '- /gallery', '', 'Docs export:', `- ${DOC_SITE.docsTextPath}`, diff --git a/doc/scripts/mdxStaticComponents.js b/doc/scripts/mdxStaticComponents.js index 5a0f777..2052a29 100644 --- a/doc/scripts/mdxStaticComponents.js +++ b/doc/scripts/mdxStaticComponents.js @@ -20,10 +20,18 @@ export const ExampleNotebook = ({ title }) => ( ) ); +export const WidgetPreview = ({ src }) => ( + React.createElement('div', { className: 'placeholder' }, + React.createElement('div', { className: 'placeholder-label' }, 'Widget Preview'), + React.createElement('div', { className: 'placeholder-caption' }, 'Open the live docs to see this interactive widget.') + ) +); + const mdxStaticComponents = { MediaPlaceholder, InstallCommand, ExampleNotebook, + WidgetPreview, }; export default mdxStaticComponents;