├── src ├── vite-env.d.ts ├── utils │ ├── operators │ │ ├── baseOperator.ts │ │ ├── index.ts │ │ ├── filterOperator.ts │ │ ├── mapOperator.ts │ │ ├── mergeOperator.ts │ │ ├── concatOperator.ts │ │ ├── combineLatestOperator.ts │ │ └── scanOperator.ts │ ├── marbleUtils │ │ ├── mapMarbleUtils.ts │ │ ├── filterMarbleUtils.ts │ │ ├── scanMarbleUtils.ts │ │ ├── baseMarbleUtils.ts │ │ ├── index.ts │ │ ├── concatMarbleUtils.ts │ │ ├── mergeMarbleUtils.ts │ │ └── combineLatestMarbleUtils.ts │ ├── marbleUtils.ts │ └── operators.ts ├── main.tsx ├── components │ ├── operators │ │ ├── displays │ │ │ ├── MapDisplay.tsx │ │ │ ├── FilterDisplay.tsx │ │ │ ├── index.ts │ │ │ ├── ScanDisplay.tsx │ │ │ ├── MergeDisplay.tsx │ │ │ ├── ConcatDisplay.tsx │ │ │ └── CombineLatestDisplay.tsx │ │ ├── ScanTimeline.tsx │ │ ├── MergeTimeline.tsx │ │ ├── ConcatTimeline.tsx │ │ ├── FilterTimeline.tsx │ │ ├── MapTimeline.tsx │ │ ├── CombineLatestTimeline.tsx │ │ └── BaseTimeline.tsx │ ├── Timeline │ │ ├── index.ts │ │ ├── labels │ │ │ ├── index.ts │ │ │ ├── MapTimelineLabel.tsx │ │ │ ├── ScanTimelineLabel.tsx │ │ │ ├── FilterTimelineLabel.tsx │ │ │ ├── MergeTimelineLabel.tsx │ │ │ └── CombineLatestTimelineLabel.tsx │ │ ├── ConcatTimeline.tsx │ │ ├── MapTimeline.tsx │ │ ├── ScanTimeline.tsx │ │ ├── FilterTimeline.tsx │ │ ├── OutputMarble.tsx │ │ ├── MergeTimeline.tsx │ │ ├── CombineLatestTimeline.tsx │ │ ├── Timeline.tsx │ │ ├── StreamMarble.tsx │ │ ├── Marble.tsx │ │ └── TimelineLabel.tsx │ ├── SpeedControl.tsx │ ├── OperatorDisplay.tsx │ ├── ThemeToggle.tsx │ ├── Timeline.tsx │ └── Controls.tsx ├── types │ └── marble.ts ├── store │ ├── themeStore.ts │ ├── operators │ │ ├── baseOperatorStore.ts │ │ ├── mapStore.ts │ │ ├── scanStore.ts │ │ ├── filterStore.ts │ │ ├── concatStore.ts │ │ ├── combineLatestStore.ts │ │ └── mergeStore.ts │ └── marbleStore.ts ├── hooks │ ├── useMarbleEmitter.ts │ ├── operators │ │ ├── useFilterStream.ts │ │ ├── useMapStream.ts │ │ ├── useMergeStream.ts │ │ ├── useConcatStream.ts │ │ ├── useCombineLatestStream.ts │ │ └── useScanStream.ts │ └── useMarbleStreams.ts ├── index.css └── App.tsx ├── dist ├── .DS_Store ├── angularspacebanner.png ├── index.html └── assets │ └── index-BLfRN02l.css ├── postcss.config.js ├── public └── angularspacebanner.png ├── tsconfig.json ├── vite.config.ts ├── README.md ├── index.html ├── .gitignore ├── tsconfig.node.json ├── tsconfig.app.json ├── tailwind.config.js ├── eslint.config.js ├── package.json └── LICENSE /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /dist/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielglejzner/rxjs-marble-diagram-visualizer/HEAD/dist/.DS_Store -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /dist/angularspacebanner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielglejzner/rxjs-marble-diagram-visualizer/HEAD/dist/angularspacebanner.png -------------------------------------------------------------------------------- /public/angularspacebanner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielglejzner/rxjs-marble-diagram-visualizer/HEAD/public/angularspacebanner.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | optimizeDeps: { 8 | exclude: ['lucide-react'], 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /src/utils/operators/baseOperator.ts: -------------------------------------------------------------------------------- 1 | import type { Marble } from '../../types/marble'; 2 | 3 | // Base delay calculation 4 | export const getOperatorDelay = (speed: number = 1): number => { 5 | const BASE_DELAY = 1200; // Base delay in milliseconds 6 | return BASE_DELAY / speed; 7 | }; -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './App.tsx'; 4 | import './index.css'; 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rxjs-marble-diagram-visualizer 2 | This is bolt.new generated code, no human intervention other than prompts! 3 | 4 | Story: https://www.angularspace.com/built-rxjs-visualizer-in-4-hours-with-ai-no-coding/ 5 | 6 | Feel free to contribute !!! 7 | 8 | Deployed to: [rxvisualizer.com](http://rxvisualizer.com) <---- 9 | -------------------------------------------------------------------------------- /src/components/operators/displays/MapDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const MapDisplay = () => ( 4 |
5 | 6 | {'map(x => x * 10)'} 7 | 8 |
9 | ); 10 | -------------------------------------------------------------------------------- /src/components/operators/displays/FilterDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const FilterDisplay = () => ( 4 |
5 | 6 | {'filter(x => x % 2 === 1)'} 7 | 8 |
9 | ); 10 | -------------------------------------------------------------------------------- /src/components/operators/displays/index.ts: -------------------------------------------------------------------------------- 1 | export { MapDisplay } from './MapDisplay'; 2 | export { FilterDisplay } from './FilterDisplay'; 3 | export { MergeDisplay } from './MergeDisplay'; 4 | export { CombineLatestDisplay } from './CombineLatestDisplay'; 5 | export { ConcatDisplay } from './ConcatDisplay'; 6 | export { ScanDisplay } from './ScanDisplay'; -------------------------------------------------------------------------------- /src/utils/operators/index.ts: -------------------------------------------------------------------------------- 1 | export { mapOperator } from './mapOperator'; 2 | export { filterOperator } from './filterOperator'; 3 | export { mergeOperator } from './mergeOperator'; 4 | export { combineLatestOperator } from './combineLatestOperator'; 5 | export { concatOperator } from './concatOperator'; 6 | export { scanOperator } from './scanOperator'; -------------------------------------------------------------------------------- /src/components/Timeline/index.ts: -------------------------------------------------------------------------------- 1 | export { MapTimeline } from './MapTimeline'; 2 | export { FilterTimeline } from './FilterTimeline'; 3 | export { MergeTimeline } from './MergeTimeline'; 4 | export { CombineLatestTimeline } from './CombineLatestTimeline'; 5 | export { ConcatTimeline } from './ConcatTimeline'; 6 | export { ScanTimeline } from './ScanTimeline'; -------------------------------------------------------------------------------- /src/components/Timeline/labels/index.ts: -------------------------------------------------------------------------------- 1 | export { MapTimelineLabel } from './MapTimelineLabel'; 2 | export { FilterTimelineLabel } from './FilterTimelineLabel'; 3 | export { MergeTimelineLabel } from './MergeTimelineLabel'; 4 | export { CombineLatestTimelineLabel } from './CombineLatestTimelineLabel'; 5 | export { ScanTimelineLabel } from './ScanTimelineLabel'; -------------------------------------------------------------------------------- /src/components/operators/displays/ScanDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const ScanDisplay = () => ( 4 |
5 | 6 | {'scan((acc, curr) => acc + curr, 0)'} 7 | 8 |
9 | ); 10 | -------------------------------------------------------------------------------- /src/utils/marbleUtils/mapMarbleUtils.ts: -------------------------------------------------------------------------------- 1 | import { createMarble } from './baseMarbleUtils'; 2 | 3 | let numberIndex = 1; 4 | 5 | export const resetMapIndexes = () => { 6 | numberIndex = 1; 7 | }; 8 | 9 | export const generateMapMarble = () => { 10 | const marble = createMarble(numberIndex, 'rgb(59, 130, 246)'); // blue 11 | numberIndex++; 12 | return marble; 13 | }; -------------------------------------------------------------------------------- /src/utils/marbleUtils/filterMarbleUtils.ts: -------------------------------------------------------------------------------- 1 | import { createMarble } from './baseMarbleUtils'; 2 | 3 | let numberIndex = 1; 4 | 5 | export const resetFilterIndexes = () => { 6 | numberIndex = 1; 7 | }; 8 | 9 | export const generateFilterMarble = () => { 10 | const marble = createMarble(numberIndex, 'rgb(59, 130, 246)'); // blue 11 | numberIndex++; 12 | return marble; 13 | }; -------------------------------------------------------------------------------- /src/utils/marbleUtils/scanMarbleUtils.ts: -------------------------------------------------------------------------------- 1 | import { createMarble } from './baseMarbleUtils'; 2 | 3 | let numberIndex = 1; 4 | 5 | export const resetScanIndexes = () => { 6 | numberIndex = 1; 7 | }; 8 | 9 | export const generateScanMarble = () => { 10 | const marble = createMarble(numberIndex, 'rgb(239, 68, 68)'); // Red for input marbles 11 | numberIndex++; 12 | return marble; 13 | }; -------------------------------------------------------------------------------- /src/utils/marbleUtils/baseMarbleUtils.ts: -------------------------------------------------------------------------------- 1 | // Base marble generation utilities 2 | export const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 3 | export const specialChars = ['@', '#', '$', '%', '&', '*', '=', '+', '?', '!']; 4 | 5 | export const createMarble = (value: string | number, color: string) => ({ 6 | id: Math.random().toString(36).substring(2), 7 | value, 8 | timestamp: Date.now(), 9 | color 10 | }); -------------------------------------------------------------------------------- /src/types/marble.ts: -------------------------------------------------------------------------------- 1 | export interface Marble { 2 | id: string; 3 | value: string | number; 4 | timestamp: number; 5 | color: string; 6 | sourceValues?: { 7 | stream1: string | number; 8 | stream2: string | number; 9 | stream3?: string | number; 10 | }; 11 | } 12 | 13 | export type RxJSOperator = 14 | | 'map' 15 | | 'filter' 16 | | 'merge' 17 | | 'combineLatest' 18 | | 'concat' 19 | | 'scan'; 20 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/utils/marbleUtils/index.ts: -------------------------------------------------------------------------------- 1 | export { generateMapMarble, resetMapIndexes } from './mapMarbleUtils'; 2 | export { generateFilterMarble, resetFilterIndexes } from './filterMarbleUtils'; 3 | export { generateMergeMarble, resetMergeIndexes } from './mergeMarbleUtils'; 4 | export { generateCombineLatestMarble, resetCombineLatestIndexes } from './combineLatestMarbleUtils'; 5 | export { generateConcatMarble, resetConcatIndexes } from './concatMarbleUtils'; 6 | export { generateScanMarble, resetScanIndexes } from './scanMarbleUtils'; -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | .pnp 4 | .pnp.js 5 | 6 | # Testing 7 | coverage 8 | 9 | # Production 10 | dist 11 | dist-ssr 12 | *.local 13 | 14 | # Logs 15 | logs 16 | *.log 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | pnpm-debug.log* 21 | 22 | # Editor directories and files 23 | .vscode/* 24 | !.vscode/extensions.json 25 | !.vscode/settings.json 26 | .idea 27 | .DS_Store 28 | *.suo 29 | *.ntvs* 30 | *.njsproj 31 | *.sln 32 | *.sw? 33 | 34 | # Environment files 35 | .env 36 | .env.* 37 | !.env.example 38 | -------------------------------------------------------------------------------- /src/components/operators/displays/MergeDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useMarbleStore } from '../../../store/marbleStore'; 3 | 4 | export const MergeDisplay = () => { 5 | const { isStream3Enabled } = useMarbleStore(); 6 | 7 | return ( 8 |
9 | 10 | {isStream3Enabled 11 | ? 'merge(stream1$, stream2$, stream3$)' 12 | : 'merge(stream1$, stream2$)'} 13 | 14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /src/components/operators/displays/ConcatDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useMarbleStore } from '../../../store/marbleStore'; 3 | 4 | export const ConcatDisplay = () => { 5 | const { isStream3Enabled } = useMarbleStore(); 6 | 7 | return ( 8 |
9 | 10 | {isStream3Enabled 11 | ? 'concat(stream1$, stream2$, stream3$)' 12 | : 'concat(stream1$, stream2$)'} 13 | 14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/store/themeStore.ts: -------------------------------------------------------------------------------- 1 | import {create} from "zustand"; 2 | 3 | type Theme = "dark" | "light"; 4 | 5 | interface ThemeStore { 6 | theme: Theme 7 | toggleTheme: () => void; 8 | } 9 | 10 | export const useThemeStore = create((set) => ({ 11 | theme: (localStorage.getItem("theme") as Theme) || "light", 12 | 13 | toggleTheme: () => 14 | set((state) => { 15 | const newTheme: Theme = state.theme === "dark" ? "light" : "dark"; 16 | localStorage.setItem("theme", newTheme); 17 | return { theme: newTheme }; 18 | }), 19 | })); 20 | -------------------------------------------------------------------------------- /src/utils/operators/filterOperator.ts: -------------------------------------------------------------------------------- 1 | import { Observable, filter, map, delay } from 'rxjs'; 2 | import type { Marble } from '../../types/marble'; 3 | import { getOperatorDelay } from './baseOperator'; 4 | 5 | export const filterOperator = ( 6 | stream1$: Observable, 7 | speed: number = 1 8 | ): Observable => { 9 | return stream1$.pipe( 10 | delay(getOperatorDelay(speed)), 11 | filter(marble => typeof marble.value === 'number' && marble.value % 2 === 1), 12 | map(marble => ({ 13 | ...marble, 14 | id: `${marble.id}-filtered`, 15 | color: 'rgb(34, 197, 94)' 16 | })) 17 | ); 18 | }; -------------------------------------------------------------------------------- /src/components/operators/displays/CombineLatestDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useMarbleStore } from '../../../store/marbleStore'; 3 | 4 | export const CombineLatestDisplay = () => { 5 | const { isStream3Enabled } = useMarbleStore(); 6 | 7 | return ( 8 |
9 | 10 | {isStream3Enabled 11 | ? 'combineLatest([stream1$, stream2$, stream3$], (x, y, z) => x + y + z)' 12 | : 'combineLatest([stream1$, stream2$], (x, y) => x + y)'} 13 | 14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/utils/operators/mapOperator.ts: -------------------------------------------------------------------------------- 1 | import { Observable, map, delay } from 'rxjs'; 2 | import type { Marble } from '../../types/marble'; 3 | import { getOperatorDelay } from './baseOperator'; 4 | 5 | export const mapOperator = ( 6 | stream1$: Observable, 7 | speed: number = 1 8 | ): Observable => { 9 | return stream1$.pipe( 10 | delay(getOperatorDelay(speed)), 11 | map(marble => ({ 12 | ...marble, 13 | id: `${marble.id}-mapped`, 14 | value: typeof marble.value === 'string' 15 | ? marble.value.toString().toLowerCase() 16 | : marble.value * 10, 17 | color: 'rgb(34, 197, 94)' 18 | })) 19 | ); 20 | }; -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/marbleUtils/concatMarbleUtils.ts: -------------------------------------------------------------------------------- 1 | import { createMarble } from './baseMarbleUtils'; 2 | 3 | let numberIndex1 = 1; 4 | let numberIndex2 = 10; 5 | 6 | export const resetConcatIndexes = () => { 7 | numberIndex1 = 1; 8 | numberIndex2 = 10; 9 | }; 10 | 11 | export const generateConcatMarble = (streamId: number) => { 12 | switch (streamId) { 13 | case 1: { 14 | const value = numberIndex1++; 15 | return createMarble(value, 'rgb(59, 130, 246)'); // blue 16 | } 17 | case 2: { 18 | const value = numberIndex2++; 19 | return createMarble(value, 'rgb(239, 68, 68)'); // red 20 | } 21 | default: 22 | return null; 23 | } 24 | }; -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 4 | darkMode: 'class', 5 | theme: { 6 | extend: { 7 | colors: { 8 | primary: 'var(--color-primary)', 9 | secondary: 'var(--color-secondary)', 10 | text: 'var(--color-text)', 11 | 'text-muted': 'var(--color-text-muted)', 12 | border: 'var(--color-border)', 13 | purple: 'var(--color-purple)', 14 | }, 15 | boxShadow: { 16 | banner: 'var(--banner-shadow)', 17 | timeline: 'var(--timeline-shadow)', 18 | }, 19 | }, 20 | }, 21 | plugins: [], 22 | }; 23 | -------------------------------------------------------------------------------- /src/utils/operators/mergeOperator.ts: -------------------------------------------------------------------------------- 1 | import { Observable, merge, map, delay } from 'rxjs'; 2 | import type { Marble } from '../../types/marble'; 3 | import { getOperatorDelay } from './baseOperator'; 4 | 5 | export const mergeOperator = ( 6 | stream1$: Observable, 7 | stream2$: Observable, 8 | stream3$: Observable, 9 | isStream3Enabled: boolean, 10 | speed: number = 1 11 | ): Observable => { 12 | const streams = isStream3Enabled 13 | ? [stream1$, stream2$, stream3$] 14 | : [stream1$, stream2$]; 15 | 16 | return merge(...streams).pipe( 17 | delay(getOperatorDelay(speed)), 18 | map(marble => ({ 19 | ...marble, 20 | id: `${marble.id}-merged`, 21 | color: 'rgb(34, 197, 94)' 22 | })) 23 | ); 24 | }; -------------------------------------------------------------------------------- /src/components/operators/ScanTimeline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BaseTimeline } from './BaseTimeline'; 3 | import { useScanStore } from '../../store/operators/scanStore'; 4 | import { useScanStream } from '../../hooks/operators/useScanStream'; 5 | 6 | export const ScanTimeline: React.FC = () => { 7 | const { 8 | stream1Marbles, 9 | stream2Marbles, 10 | stream3Marbles, 11 | outputMarbles, 12 | isStream3Enabled 13 | } = useScanStore(); 14 | 15 | useScanStream(); 16 | 17 | return ( 18 | 26 | ); 27 | }; -------------------------------------------------------------------------------- /src/hooks/useMarbleEmitter.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useMarbleStore } from '../store/marbleStore'; 3 | import { generateMarble } from '../utils/marbleUtils'; 4 | 5 | export const useMarbleEmitter = () => { 6 | const { isPlaying, speed, addMarble } = useMarbleStore(); 7 | 8 | useEffect(() => { 9 | if (!isPlaying) return; 10 | 11 | const interval1 = setInterval(() => { 12 | const marble = generateMarble(); 13 | addMarble(1, marble); 14 | }, 2000 / speed); 15 | 16 | const interval2 = setInterval(() => { 17 | const marble = generateMarble(); 18 | addMarble(2, marble); 19 | }, 2500 / speed); 20 | 21 | return () => { 22 | clearInterval(interval1); 23 | clearInterval(interval2); 24 | }; 25 | }, [isPlaying, speed, addMarble]); 26 | }; -------------------------------------------------------------------------------- /src/components/operators/MergeTimeline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BaseTimeline } from './BaseTimeline'; 3 | import { useMergeStore } from '../../store/operators/mergeStore'; 4 | import { useMergeStream } from '../../hooks/operators/useMergeStream'; 5 | 6 | export const MergeTimeline: React.FC = () => { 7 | const { 8 | stream1Marbles, 9 | stream2Marbles, 10 | stream3Marbles, 11 | outputMarbles, 12 | isStream3Enabled 13 | } = useMergeStore(); 14 | 15 | useMergeStream(); 16 | 17 | return ( 18 | 26 | ); 27 | }; -------------------------------------------------------------------------------- /src/components/SpeedControl.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Gauge } from 'lucide-react'; 3 | import { useMarbleStore } from '../store/marbleStore'; 4 | 5 | export const SpeedControl: React.FC = () => { 6 | const { speed, setSpeed } = useMarbleStore(); 7 | 8 | return ( 9 |
10 | 11 | setSpeed(parseFloat(e.target.value))} 18 | className="w-24 h-2 bg-border rounded-lg appearance-none cursor-pointer" 19 | /> 20 | 21 | {speed}x 22 | 23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/operators/ConcatTimeline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BaseTimeline } from './BaseTimeline'; 3 | import { useConcatStore } from '../../store/operators/concatStore'; 4 | import { useConcatStream } from '../../hooks/operators/useConcatStream'; 5 | 6 | export const ConcatTimeline: React.FC = () => { 7 | const { 8 | stream1Marbles, 9 | stream2Marbles, 10 | stream3Marbles, 11 | outputMarbles, 12 | isStream3Enabled 13 | } = useConcatStore(); 14 | 15 | useConcatStream(); 16 | 17 | return ( 18 | 26 | ); 27 | }; -------------------------------------------------------------------------------- /src/components/operators/FilterTimeline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BaseTimeline } from './BaseTimeline'; 3 | import { useFilterStore } from '../../store/operators/filterStore'; 4 | import { useFilterStream } from '../../hooks/operators/useFilterStream'; 5 | 6 | export const FilterTimeline: React.FC = () => { 7 | const { 8 | stream1Marbles, 9 | stream2Marbles, 10 | stream3Marbles, 11 | outputMarbles, 12 | isStream3Enabled 13 | } = useFilterStore(); 14 | 15 | useFilterStream(); 16 | 17 | return ( 18 | 26 | ); 27 | }; -------------------------------------------------------------------------------- /src/components/operators/MapTimeline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BaseTimeline } from './BaseTimeline'; 3 | import { useMapStore } from '../../store/operators/mapStore'; 4 | import { useMapStream } from '../../hooks/operators/useMapStream'; 5 | 6 | export const MapTimeline: React.FC = () => { 7 | const { 8 | stream1Marbles, 9 | stream2Marbles, 10 | stream3Marbles, 11 | outputMarbles, 12 | isStream3Enabled 13 | } = useMapStore(); 14 | 15 | useMapStream(); 16 | 17 | return ( 18 | 26 | ); 27 | }; -------------------------------------------------------------------------------- /src/utils/operators/concatOperator.ts: -------------------------------------------------------------------------------- 1 | import { Observable, concat, map, take, endWith, delay } from 'rxjs'; 2 | import type { Marble } from '../../types/marble'; 3 | 4 | export const concatOperator = ( 5 | stream1$: Observable, 6 | stream2$: Observable 7 | ): Observable => { 8 | const stream1WithEnd$ = stream1$.pipe( 9 | take(3), 10 | endWith(null) 11 | ); 12 | 13 | const stream2WithEnd$ = stream2$.pipe( 14 | take(3), 15 | endWith(null) 16 | ); 17 | 18 | return concat(stream1WithEnd$, stream2WithEnd$).pipe( 19 | delay(1200), // Reduced from 1500ms to 1200ms 20 | map(marble => { 21 | if (!marble) return null; 22 | return { 23 | ...marble, 24 | id: `${marble.id}-concat`, 25 | color: 'rgb(34, 197, 94)' 26 | }; 27 | }) 28 | ); 29 | }; -------------------------------------------------------------------------------- /src/components/OperatorDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useMarbleStore } from '../store/marbleStore'; 3 | import { 4 | MapDisplay, 5 | FilterDisplay, 6 | MergeDisplay, 7 | CombineLatestDisplay, 8 | ConcatDisplay, 9 | ScanDisplay 10 | } from './operators/displays'; 11 | 12 | export const OperatorDisplay: React.FC = () => { 13 | const { currentOperator } = useMarbleStore(); 14 | 15 | switch (currentOperator) { 16 | case 'map': 17 | return ; 18 | case 'filter': 19 | return ; 20 | case 'merge': 21 | return ; 22 | case 'combineLatest': 23 | return ; 24 | case 'concat': 25 | return ; 26 | case 'scan': 27 | return ; 28 | default: 29 | return null; 30 | } 31 | }; -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import globals from 'globals'; 3 | import reactHooks from 'eslint-plugin-react-hooks'; 4 | import reactRefresh from 'eslint-plugin-react-refresh'; 5 | import tseslint from 'typescript-eslint'; 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | } 28 | ); 29 | -------------------------------------------------------------------------------- /src/store/operators/baseOperatorStore.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs'; 2 | import type { Marble } from '../../types/marble'; 3 | 4 | export interface BaseOperatorState { 5 | stream1Marbles: Marble[]; 6 | stream2Marbles: Marble[]; 7 | stream3Marbles: Marble[]; 8 | outputMarbles: Marble[]; 9 | stream1$: Subject; 10 | stream2$: Subject; 11 | stream3$: Subject; 12 | isStream3Enabled: boolean; 13 | speed: number; 14 | addMarble: (streamId: number, marble: Marble) => void; 15 | addOutputMarble: (marble: Marble) => void; 16 | setSpeed: (speed: number) => void; 17 | clearMarbles: () => void; 18 | resetPipeline: () => void; 19 | toggleStream3: () => void; 20 | } 21 | 22 | export const createNewSubjects = () => ({ 23 | stream1$: new Subject(), 24 | stream2$: new Subject(), 25 | stream3$: new Subject() 26 | }); -------------------------------------------------------------------------------- /src/components/operators/CombineLatestTimeline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BaseTimeline } from './BaseTimeline'; 3 | import { useCombineLatestStore } from '../../store/operators/combineLatestStore'; 4 | import { useCombineLatestStream } from '../../hooks/operators/useCombineLatestStream'; 5 | 6 | export const CombineLatestTimeline: React.FC = () => { 7 | const { 8 | stream1Marbles, 9 | stream2Marbles, 10 | stream3Marbles, 11 | outputMarbles, 12 | isStream3Enabled 13 | } = useCombineLatestStore(); 14 | 15 | useCombineLatestStream(); 16 | 17 | return ( 18 | 26 | ); 27 | }; -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --color-primary: #f3f4f6; 8 | --color-secondary: #ffffff; 9 | --color-text: #111827; 10 | --color-text-muted: #6b7280; 11 | --color-border: #e5e7eb; 12 | --color-purple: #a855f7; 13 | --color-purple-hover: #a855f7; 14 | --color-purple-glow: rgba(168, 85, 247, 0.4); 15 | --banner-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); 16 | --timeline-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); 17 | } 18 | 19 | .dark { 20 | --color-primary: #111827; 21 | --color-secondary: #1f2937; 22 | --color-text: #f3f4f6; 23 | --color-text-muted: #9ca3af; 24 | --color-border: #374151; 25 | --color-purple: #c084fc; 26 | --color-purple-glow: rgba(192, 132, 252, 0.4); 27 | --banner-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3); 28 | --timeline-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rxjs-marble-visualizer", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "lucide-react": "^0.344.0", 14 | "react": "^18.3.1", 15 | "react-dom": "^18.3.1", 16 | "rxjs": "^7.8.1", 17 | "framer-motion": "^11.0.8", 18 | "zustand": "^4.5.2" 19 | }, 20 | "devDependencies": { 21 | "@eslint/js": "^9.9.1", 22 | "@types/react": "^18.3.5", 23 | "@types/react-dom": "^18.3.0", 24 | "@vitejs/plugin-react": "^4.3.1", 25 | "autoprefixer": "^10.4.18", 26 | "eslint": "^9.9.1", 27 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 28 | "eslint-plugin-react-refresh": "^0.4.11", 29 | "globals": "^15.9.0", 30 | "postcss": "^8.4.35", 31 | "tailwindcss": "^3.4.1", 32 | "typescript": "^5.5.3", 33 | "typescript-eslint": "^8.3.0", 34 | "vite": "^5.4.2" 35 | } 36 | } -------------------------------------------------------------------------------- /src/utils/marbleUtils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | generateMapMarble, 3 | generateFilterMarble, 4 | generateMergeMarble, 5 | generateCombineLatestMarble, 6 | generateConcatMarble, 7 | generateScanMarble, 8 | resetMapIndexes, 9 | resetFilterIndexes, 10 | resetMergeIndexes, 11 | resetCombineLatestIndexes, 12 | resetConcatIndexes, 13 | resetScanIndexes 14 | } from './marbleUtils/index'; 15 | 16 | // Re-export all marble utilities 17 | export { 18 | generateMapMarble, 19 | generateFilterMarble, 20 | generateMergeMarble, 21 | generateCombineLatestMarble, 22 | generateConcatMarble, 23 | generateScanMarble, 24 | resetMapIndexes, 25 | resetFilterIndexes, 26 | resetMergeIndexes, 27 | resetCombineLatestIndexes, 28 | resetConcatIndexes, 29 | resetScanIndexes 30 | }; 31 | 32 | // Combined reset function 33 | export const resetIndexes = () => { 34 | resetMapIndexes(); 35 | resetFilterIndexes(); 36 | resetMergeIndexes(); 37 | resetCombineLatestIndexes(); 38 | resetConcatIndexes(); 39 | resetScanIndexes(); 40 | }; -------------------------------------------------------------------------------- /src/utils/marbleUtils/mergeMarbleUtils.ts: -------------------------------------------------------------------------------- 1 | import { createMarble } from './baseMarbleUtils'; 2 | import { letters } from './baseMarbleUtils'; 3 | 4 | let numberIndex1 = 20; 5 | let numberIndex2 = 1; 6 | let letterIndex = 0; 7 | 8 | export const resetMergeIndexes = () => { 9 | numberIndex1 = 20; 10 | numberIndex2 = 1; 11 | letterIndex = 0; 12 | }; 13 | 14 | export const generateMergeMarble = (streamId: number) => { 15 | switch (streamId) { 16 | case 1: { 17 | const value = numberIndex1; 18 | numberIndex1 += 20; 19 | return createMarble(value, 'rgb(59, 130, 246)'); // blue 20 | } 21 | case 2: { 22 | const value = numberIndex2; 23 | numberIndex2 += 1; 24 | return createMarble(value, 'rgb(239, 68, 68)'); // red 25 | } 26 | case 3: { 27 | const letter = letters[letterIndex % letters.length]; 28 | letterIndex++; 29 | return createMarble(letter, 'rgb(168, 85, 247)'); // purple 30 | } 31 | default: 32 | return createMarble('?', 'rgb(156, 163, 175)'); // gray 33 | } 34 | }; -------------------------------------------------------------------------------- /src/utils/marbleUtils/combineLatestMarbleUtils.ts: -------------------------------------------------------------------------------- 1 | import { createMarble } from './baseMarbleUtils'; 2 | import { letters, specialChars } from './baseMarbleUtils'; 3 | 4 | let letterIndex = 0; 5 | let numberIndex = 1; 6 | let specialCharIndex = 0; 7 | 8 | export const resetCombineLatestIndexes = () => { 9 | letterIndex = 0; 10 | numberIndex = 1; 11 | specialCharIndex = 0; 12 | }; 13 | 14 | export const generateCombineLatestMarble = (streamId: number) => { 15 | switch (streamId) { 16 | case 1: 17 | const letter = letters[letterIndex % letters.length]; 18 | letterIndex++; 19 | return createMarble(letter, 'rgb(59, 130, 246)'); // blue 20 | case 2: 21 | const number = numberIndex++; 22 | return createMarble(number, 'rgb(239, 68, 68)'); // red 23 | case 3: 24 | const special = specialChars[specialCharIndex % specialChars.length]; 25 | specialCharIndex++; 26 | return createMarble(special, 'rgb(168, 85, 247)'); // purple 27 | default: 28 | return createMarble('?', 'rgb(156, 163, 175)'); // gray 29 | } 30 | }; -------------------------------------------------------------------------------- /src/hooks/operators/useFilterStream.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { filter, map } from 'rxjs'; 3 | import { useFilterStore } from '../../store/operators/filterStore'; 4 | 5 | export const useFilterStream = () => { 6 | const { 7 | stream1$, 8 | addOutputMarble 9 | } = useFilterStore(); 10 | 11 | useEffect(() => { 12 | const subscription = stream1$.pipe( 13 | filter(marble => typeof marble.value === 'number' ? marble.value % 2 === 0 : true), 14 | map(marble => ({ 15 | ...marble, 16 | id: `${marble.id}-filtered`, 17 | color: 'rgb(34, 197, 94)' 18 | })) 19 | ).subscribe({ 20 | next: marble => { 21 | if (marble && marble.value !== undefined && marble.value !== null) { 22 | addOutputMarble({ 23 | ...marble, 24 | timestamp: Date.now(), 25 | id: marble.id || Math.random().toString(36).substr(2), 26 | }); 27 | } 28 | } 29 | }); 30 | 31 | return () => subscription.unsubscribe(); 32 | }, [stream1$, addOutputMarble]); 33 | }; -------------------------------------------------------------------------------- /src/hooks/operators/useMapStream.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { map } from 'rxjs'; 3 | import { useMapStore } from '../../store/operators/mapStore'; 4 | 5 | export const useMapStream = () => { 6 | const { 7 | stream1$, 8 | addOutputMarble 9 | } = useMapStore(); 10 | 11 | useEffect(() => { 12 | const subscription = stream1$.pipe( 13 | map(marble => ({ 14 | ...marble, 15 | id: `${marble.id}-mapped`, 16 | value: typeof marble.value === 'string' 17 | ? marble.value.toString().toLowerCase() 18 | : marble.value * 10, 19 | color: 'rgb(34, 197, 94)' 20 | })) 21 | ).subscribe({ 22 | next: marble => { 23 | if (marble && marble.value !== undefined && marble.value !== null) { 24 | addOutputMarble({ 25 | ...marble, 26 | timestamp: Date.now(), 27 | id: marble.id || Math.random().toString(36).substr(2), 28 | }); 29 | } 30 | } 31 | }); 32 | 33 | return () => subscription.unsubscribe(); 34 | }, [stream1$, addOutputMarble]); 35 | }; -------------------------------------------------------------------------------- /src/utils/operators/combineLatestOperator.ts: -------------------------------------------------------------------------------- 1 | import { Observable, combineLatest, map, delay } from 'rxjs'; 2 | import type { Marble } from '../../types/marble'; 3 | import { getOperatorDelay } from './baseOperator'; 4 | 5 | export const combineLatestOperator = ( 6 | stream1$: Observable, 7 | stream2$: Observable, 8 | stream3$: Observable, 9 | isStream3Enabled: boolean, 10 | speed: number = 1 11 | ): Observable => { 12 | const streams$ = isStream3Enabled 13 | ? { s1: stream1$, s2: stream2$, s3: stream3$ } 14 | : { s1: stream1$, s2: stream2$ }; 15 | 16 | return combineLatest(streams$).pipe( 17 | delay(getOperatorDelay(speed)), 18 | map(combined => ({ 19 | id: Object.values(combined).map(m => m.id).join('-'), 20 | value: Object.values(combined).map(m => m.value).join(''), 21 | timestamp: Date.now(), 22 | color: 'rgb(34, 197, 94)', 23 | sourceValues: { 24 | stream1: combined.s1.value, 25 | stream2: combined.s2.value, 26 | stream3: isStream3Enabled ? combined.s3.value : '' 27 | } 28 | })) 29 | ); 30 | }; -------------------------------------------------------------------------------- /src/components/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import {useThemeStore} from "../store/themeStore"; 3 | import {Moon, Sun} from "lucide-react"; 4 | 5 | 6 | export const ThemeToggle: React.FC = () => { 7 | const { theme, toggleTheme } = useThemeStore(); 8 | 9 | useEffect(() => { 10 | if (theme === "dark") { 11 | document.documentElement.classList.add("dark"); 12 | document.documentElement.classList.remove("light"); 13 | } else { 14 | document.documentElement.classList.add("light"); 15 | document.documentElement.classList.remove("dark"); 16 | } 17 | }, [theme]); 18 | 19 | 20 | return ( 21 | 32 | ) 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/Timeline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useMarbleStore } from '../store/marbleStore'; 3 | import { 4 | MapTimeline, 5 | FilterTimeline, 6 | MergeTimeline, 7 | CombineLatestTimeline, 8 | ConcatTimeline, 9 | ScanTimeline 10 | } from './Timeline/index'; 11 | import type { Marble as MarbleType } from '../types/marble'; 12 | 13 | interface TimelineProps { 14 | marbles: MarbleType[]; 15 | label: string; 16 | streamId?: number; 17 | isOutput?: boolean; 18 | } 19 | 20 | export const Timeline: React.FC = (props) => { 21 | const { currentOperator } = useMarbleStore(); 22 | 23 | switch (currentOperator) { 24 | case 'map': 25 | return ; 26 | case 'filter': 27 | return ; 28 | case 'merge': 29 | return ; 30 | case 'combineLatest': 31 | return ; 32 | case 'concat': 33 | return ; 34 | case 'scan': 35 | return ; 36 | default: 37 | return null; 38 | } 39 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Daniel Glejzner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/Timeline/ConcatTimeline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Marble } from './Marble'; 3 | import { TimelineLabel } from './TimelineLabel'; 4 | import type { Marble as MarbleType } from '../../types/marble'; 5 | 6 | interface TimelineProps { 7 | marbles: MarbleType[]; 8 | label: string; 9 | streamId?: number; 10 | isOutput?: boolean; 11 | } 12 | 13 | export const ConcatTimeline: React.FC = ({ 14 | marbles = [], 15 | label, 16 | streamId, 17 | isOutput = false 18 | }) => { 19 | // Only show Stream 1, Stream 2, and Output 20 | if (streamId === 3) { 21 | return null; 22 | } 23 | 24 | return ( 25 |
26 | 27 |
28 |
29 |
30 | {marbles?.map((marble) => ( 31 | marble && 32 | ))} 33 |
34 |
35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/utils/operators.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import type { Marble, RxJSOperator } from '../types/marble'; 3 | import { 4 | mapOperator, 5 | filterOperator, 6 | mergeOperator, 7 | combineLatestOperator, 8 | concatOperator, 9 | scanOperator 10 | } from './operators/index'; 11 | 12 | export const applyOperator = ( 13 | stream1$: Observable, 14 | stream2$: Observable, 15 | stream3$: Observable, 16 | operator: RxJSOperator, 17 | isStream3Enabled: boolean, 18 | speed: number = 1 19 | ): Observable => { 20 | switch (operator) { 21 | case 'map': 22 | return mapOperator(stream1$, speed); 23 | case 'filter': 24 | return filterOperator(stream1$, speed); 25 | case 'merge': 26 | return mergeOperator(stream1$, stream2$, stream3$, isStream3Enabled, speed); 27 | case 'combineLatest': 28 | return combineLatestOperator(stream1$, stream2$, stream3$, isStream3Enabled, speed); 29 | case 'concat': 30 | return concatOperator(stream1$, stream2$); 31 | case 'scan': 32 | return scanOperator(stream1$, speed); 33 | default: 34 | return stream1$; 35 | } 36 | }; -------------------------------------------------------------------------------- /src/components/operators/BaseTimeline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Timeline } from '../Timeline'; 3 | import { OperatorDisplay } from '../OperatorDisplay'; 4 | import type { Marble } from '../../types/marble'; 5 | 6 | interface BaseTimelineProps { 7 | stream1Marbles: Marble[]; 8 | stream2Marbles: Marble[]; 9 | stream3Marbles: Marble[]; 10 | outputMarbles: Marble[]; 11 | operatorName: string; 12 | isStream3Enabled: boolean; 13 | } 14 | 15 | export const BaseTimeline: React.FC = ({ 16 | stream1Marbles, 17 | stream2Marbles, 18 | stream3Marbles, 19 | outputMarbles, 20 | operatorName, 21 | isStream3Enabled 22 | }) => { 23 | return ( 24 |
25 | 26 | 27 | {isStream3Enabled && ( 28 | 29 | )} 30 | 31 | 32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/hooks/operators/useMergeStream.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { merge, map } from 'rxjs'; 3 | import { useMergeStore } from '../../store/operators/mergeStore'; 4 | 5 | export const useMergeStream = () => { 6 | const { 7 | stream1$, 8 | stream2$, 9 | stream3$, 10 | isStream3Enabled, 11 | addOutputMarble 12 | } = useMergeStore(); 13 | 14 | useEffect(() => { 15 | const streams = isStream3Enabled 16 | ? [stream1$, stream2$, stream3$] 17 | : [stream1$, stream2$]; 18 | 19 | const subscription = merge(...streams).pipe( 20 | map(marble => ({ 21 | ...marble, 22 | id: `${marble.id}-merged`, 23 | color: 'rgb(34, 197, 94)' 24 | })) 25 | ).subscribe({ 26 | next: marble => { 27 | if (marble && marble.value !== undefined && marble.value !== null) { 28 | addOutputMarble({ 29 | ...marble, 30 | timestamp: Date.now(), 31 | id: marble.id || Math.random().toString(36).substr(2), 32 | }); 33 | } 34 | } 35 | }); 36 | 37 | return () => subscription.unsubscribe(); 38 | }, [stream1$, stream2$, stream3$, isStream3Enabled, addOutputMarble]); 39 | }; -------------------------------------------------------------------------------- /src/utils/operators/scanOperator.ts: -------------------------------------------------------------------------------- 1 | import { Observable, scan, delay } from 'rxjs'; 2 | import type { Marble } from '../../types/marble'; 3 | import { getOperatorDelay } from './baseOperator'; 4 | 5 | export const scanOperator = ( 6 | stream1$: Observable, 7 | speed: number = 1 8 | ): Observable => { 9 | let isFirstValue = true; 10 | 11 | return stream1$.pipe( 12 | delay(getOperatorDelay(speed)), 13 | scan((acc, marble) => { 14 | if (isFirstValue) { 15 | isFirstValue = false; 16 | return { 17 | ...marble, 18 | id: `${marble.id}-scanned`, 19 | color: 'rgb(34, 197, 94)' 20 | }; 21 | } 22 | 23 | try { 24 | return { 25 | ...marble, 26 | id: `${marble.id}-scanned`, 27 | value: typeof acc.value === 'number' && typeof marble.value === 'number' 28 | ? acc.value + marble.value 29 | : marble.value, 30 | color: 'rgb(34, 197, 94)' 31 | }; 32 | } catch (error) { 33 | throw error; 34 | } 35 | }, { 36 | id: '', 37 | value: 0, 38 | timestamp: Date.now(), 39 | color: 'rgb(34, 197, 94)' 40 | }) 41 | ); 42 | }; -------------------------------------------------------------------------------- /src/components/Timeline/MapTimeline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Marble } from './Marble'; 3 | import { MapTimelineLabel } from './labels'; 4 | import type { Marble as MarbleType } from '../../types/marble'; 5 | 6 | interface TimelineProps { 7 | marbles: MarbleType[]; 8 | label: string; 9 | streamId?: number; 10 | isOutput?: boolean; 11 | } 12 | 13 | export const MapTimeline: React.FC = ({ 14 | marbles = [], 15 | label, 16 | streamId, 17 | isOutput = false 18 | }) => { 19 | // Map only shows Stream 1 20 | if (streamId && streamId !== 1 && !isOutput) { 21 | return null; 22 | } 23 | 24 | return ( 25 |
26 | 27 |
28 | {/* Horizontal line */} 29 |
30 | 31 | {/* Marbles container */} 32 |
33 | {marbles?.map((marble) => ( 34 | marble && 35 | ))} 36 |
37 |
38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/Timeline/ScanTimeline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Marble } from './Marble'; 3 | import { ScanTimelineLabel } from './labels'; 4 | import type { Marble as MarbleType } from '../../types/marble'; 5 | 6 | interface TimelineProps { 7 | marbles: MarbleType[]; 8 | label: string; 9 | streamId?: number; 10 | isOutput?: boolean; 11 | } 12 | 13 | export const ScanTimeline: React.FC = ({ 14 | marbles = [], 15 | label, 16 | streamId, 17 | isOutput = false 18 | }) => { 19 | // Scan only shows Stream 1 20 | if (streamId && streamId !== 1 && !isOutput) { 21 | return null; 22 | } 23 | 24 | return ( 25 |
26 | 27 |
28 | {/* Horizontal line */} 29 |
30 | 31 | {/* Marbles container */} 32 |
33 | {marbles?.map((marble) => ( 34 | marble && 35 | ))} 36 |
37 |
38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/Timeline/labels/MapTimelineLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Plus } from 'lucide-react'; 3 | import { useMarbleStore } from '../../../store/marbleStore'; 4 | import { generateMapMarble } from '../../../utils/marbleUtils'; 5 | 6 | interface TimelineLabelProps { 7 | label: string; 8 | streamId?: number; 9 | } 10 | 11 | export const MapTimelineLabel: React.FC = ({ label, streamId }) => { 12 | const { addMarble } = useMarbleStore(); 13 | 14 | const handleAddMarble = () => { 15 | if (streamId !== 1) return; // Map only uses Stream 1 16 | const marble = generateMapMarble(); 17 | addMarble(streamId, marble); 18 | }; 19 | 20 | return ( 21 |
22 | {streamId === 1 && ( 23 | 30 | )} 31 | {label} 32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/Timeline/FilterTimeline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Marble } from './Marble'; 3 | import { FilterTimelineLabel } from './labels'; 4 | import type { Marble as MarbleType } from '../../types/marble'; 5 | 6 | interface TimelineProps { 7 | marbles: MarbleType[]; 8 | label: string; 9 | streamId?: number; 10 | isOutput?: boolean; 11 | } 12 | 13 | export const FilterTimeline: React.FC = ({ 14 | marbles = [], 15 | label, 16 | streamId, 17 | isOutput = false 18 | }) => { 19 | // Filter only shows Stream 1 20 | if (streamId && streamId !== 1 && !isOutput) { 21 | return null; 22 | } 23 | 24 | return ( 25 |
26 | 27 |
28 | {/* Horizontal line */} 29 |
30 | 31 | {/* Marbles container */} 32 |
33 | {marbles?.map((marble) => ( 34 | marble && 35 | ))} 36 |
37 |
38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/Timeline/labels/ScanTimelineLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Plus } from 'lucide-react'; 3 | import { useMarbleStore } from '../../../store/marbleStore'; 4 | import { generateScanMarble } from '../../../utils/marbleUtils'; 5 | 6 | interface TimelineLabelProps { 7 | label: string; 8 | streamId?: number; 9 | } 10 | 11 | export const ScanTimelineLabel: React.FC = ({ label, streamId }) => { 12 | const { addMarble } = useMarbleStore(); 13 | 14 | const handleAddMarble = () => { 15 | if (streamId !== 1) return; // Scan only uses Stream 1 16 | const marble = generateScanMarble(); 17 | addMarble(streamId, marble); 18 | }; 19 | 20 | return ( 21 |
22 | {streamId === 1 && ( 23 | 30 | )} 31 | {label} 32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/Timeline/labels/FilterTimelineLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Plus } from 'lucide-react'; 3 | import { useMarbleStore } from '../../../store/marbleStore'; 4 | import { generateFilterMarble } from '../../../utils/marbleUtils'; 5 | 6 | interface TimelineLabelProps { 7 | label: string; 8 | streamId?: number; 9 | } 10 | 11 | export const FilterTimelineLabel: React.FC = ({ label, streamId }) => { 12 | const { addMarble } = useMarbleStore(); 13 | 14 | const handleAddMarble = () => { 15 | if (streamId !== 1) return; // Filter only uses Stream 1 16 | const marble = generateFilterMarble(); 17 | addMarble(streamId, marble); 18 | }; 19 | 20 | return ( 21 |
22 | {streamId === 1 && ( 23 | 30 | )} 31 | {label} 32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/hooks/useMarbleStreams.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useMarbleStore } from '../store/marbleStore'; 3 | import { applyOperator } from '../utils/operators'; 4 | 5 | export const useMarbleStreams = () => { 6 | const { 7 | stream1$, 8 | stream2$, 9 | stream3$, 10 | currentOperator, 11 | addOutputMarble, 12 | isStream3Enabled, 13 | speed 14 | } = useMarbleStore(); 15 | 16 | useEffect(() => { 17 | const subscription = applyOperator( 18 | stream1$, 19 | stream2$, 20 | stream3$, 21 | currentOperator, 22 | isStream3Enabled, 23 | speed 24 | ).subscribe({ 25 | next: marble => { 26 | if (marble && marble.value !== undefined && marble.value !== null) { 27 | addOutputMarble({ 28 | ...marble, 29 | timestamp: Date.now(), 30 | id: marble.id || Math.random().toString(36).substr(2), 31 | }); 32 | } 33 | }, 34 | error: (err) => console.error('Stream error:', err), 35 | complete: () => console.log('Stream completed') 36 | }); 37 | 38 | return () => { 39 | subscription.unsubscribe(); 40 | }; 41 | }, [stream1$, stream2$, stream3$, currentOperator, isStream3Enabled, speed, addOutputMarble]); 42 | }; -------------------------------------------------------------------------------- /src/hooks/operators/useConcatStream.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { concat, take, endWith, map } from 'rxjs'; 3 | import { useConcatStore } from '../../store/operators/concatStore'; 4 | 5 | export const useConcatStream = () => { 6 | const { 7 | stream1$, 8 | stream2$, 9 | addOutputMarble 10 | } = useConcatStore(); 11 | 12 | useEffect(() => { 13 | const stream1WithEnd$ = stream1$.pipe( 14 | take(3), 15 | endWith(null) 16 | ); 17 | 18 | const stream2WithEnd$ = stream2$.pipe( 19 | take(3), 20 | endWith(null) 21 | ); 22 | 23 | const subscription = concat(stream1WithEnd$, stream2WithEnd$).pipe( 24 | map(marble => { 25 | if (!marble) return null; 26 | return { 27 | ...marble, 28 | id: `${marble.id}-concat`, 29 | color: 'rgb(34, 197, 94)' 30 | }; 31 | }) 32 | ).subscribe({ 33 | next: marble => { 34 | if (marble) { 35 | addOutputMarble({ 36 | ...marble, 37 | timestamp: Date.now(), 38 | id: marble.id || Math.random().toString(36).substr(2), 39 | }); 40 | } 41 | } 42 | }); 43 | 44 | return () => subscription.unsubscribe(); 45 | }, [stream1$, stream2$, addOutputMarble]); 46 | }; -------------------------------------------------------------------------------- /src/components/Timeline/OutputMarble.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { motion } from 'framer-motion'; 3 | import { useMarbleStore } from '../../store/marbleStore'; 4 | import type { Marble as MarbleType } from '../../types/marble'; 5 | 6 | interface OutputMarbleProps { 7 | marble: MarbleType; 8 | } 9 | 10 | export const OutputMarble: React.FC = ({ marble }) => { 11 | const { speed } = useMarbleStore(); 12 | const displayValue = marble.sourceValues 13 | ? `${marble.sourceValues.stream1}${marble.sourceValues.stream2}${marble.sourceValues.stream3}` 14 | : marble.value; 15 | 16 | return ( 17 | 31 |
35 | {displayValue} 36 |
37 |
38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/Timeline/MergeTimeline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Marble } from './Marble'; 3 | import { MergeTimelineLabel } from './labels'; 4 | import { useMarbleStore } from '../../store/marbleStore'; 5 | import type { Marble as MarbleType } from '../../types/marble'; 6 | 7 | interface TimelineProps { 8 | marbles: MarbleType[]; 9 | label: string; 10 | streamId?: number; 11 | isOutput?: boolean; 12 | } 13 | 14 | export const MergeTimeline: React.FC = ({ 15 | marbles = [], 16 | label, 17 | streamId, 18 | isOutput = false 19 | }) => { 20 | const { isStream3Enabled } = useMarbleStore(); 21 | 22 | if (streamId === 3 && !isStream3Enabled) { 23 | return null; 24 | } 25 | 26 | return ( 27 |
28 | 29 |
30 | {/* Horizontal line */} 31 |
32 | 33 | {/* Marbles container */} 34 |
35 | {marbles?.map((marble) => ( 36 | marble && 37 | ))} 38 |
39 |
40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/Timeline/CombineLatestTimeline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Marble } from './Marble'; 3 | import { CombineLatestTimelineLabel } from './labels'; 4 | import { useMarbleStore } from '../../store/marbleStore'; 5 | import type { Marble as MarbleType } from '../../types/marble'; 6 | 7 | interface TimelineProps { 8 | marbles: MarbleType[]; 9 | label: string; 10 | streamId?: number; 11 | isOutput?: boolean; 12 | } 13 | 14 | export const CombineLatestTimeline: React.FC = ({ 15 | marbles = [], 16 | label, 17 | streamId, 18 | isOutput = false 19 | }) => { 20 | const { isStream3Enabled } = useMarbleStore(); 21 | 22 | if (streamId === 3 && !isStream3Enabled) { 23 | return null; 24 | } 25 | 26 | return ( 27 |
28 | 29 |
30 | {/* Horizontal line */} 31 |
32 | 33 | {/* Marbles container */} 34 |
35 | {marbles?.map((marble) => ( 36 | marble && 37 | ))} 38 |
39 |
40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/hooks/operators/useCombineLatestStream.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { combineLatest, map } from 'rxjs'; 3 | import { useCombineLatestStore } from '../../store/operators/combineLatestStore'; 4 | 5 | export const useCombineLatestStream = () => { 6 | const { 7 | stream1$, 8 | stream2$, 9 | stream3$, 10 | isStream3Enabled, 11 | addOutputMarble 12 | } = useCombineLatestStore(); 13 | 14 | useEffect(() => { 15 | const streams$ = isStream3Enabled 16 | ? { s1: stream1$, s2: stream2$, s3: stream3$ } 17 | : { s1: stream1$, s2: stream2$ }; 18 | 19 | const subscription = combineLatest(streams$).pipe( 20 | map(combined => ({ 21 | id: Object.values(combined).map(m => m.id).join('-'), 22 | value: Object.values(combined).map(m => m.value).join(''), 23 | timestamp: Date.now(), 24 | color: 'rgb(34, 197, 94)', 25 | sourceValues: { 26 | stream1: combined.s1.value, 27 | stream2: combined.s2.value, 28 | stream3: isStream3Enabled ? combined.s3.value : '' 29 | } 30 | })) 31 | ).subscribe({ 32 | next: marble => { 33 | if (marble && marble.value !== undefined && marble.value !== null) { 34 | addOutputMarble({ 35 | ...marble, 36 | timestamp: Date.now(), 37 | id: marble.id || Math.random().toString(36).substr(2), 38 | }); 39 | } 40 | } 41 | }); 42 | 43 | return () => subscription.unsubscribe(); 44 | }, [stream1$, stream2$, stream3$, isStream3Enabled, addOutputMarble]); 45 | }; -------------------------------------------------------------------------------- /src/components/Timeline/Timeline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useMarbleStore } from '../../store/marbleStore'; 3 | import { StreamMarble } from './StreamMarble'; 4 | import { OutputMarble } from './OutputMarble'; 5 | import { TimelineLabel } from './TimelineLabel'; 6 | import type { Marble as MarbleType } from '../../types/marble'; 7 | 8 | interface TimelineProps { 9 | marbles: MarbleType[]; 10 | label: string; 11 | streamId?: number; 12 | isOutput?: boolean; 13 | } 14 | 15 | export const Timeline: React.FC = ({ 16 | marbles = [], 17 | label, 18 | streamId, 19 | isOutput = false 20 | }) => { 21 | const { isStream3Enabled, emitOutput } = useMarbleStore(); 22 | 23 | if (streamId === 3 && !isStream3Enabled) { 24 | return null; 25 | } 26 | 27 | const handleMarbleCrossLine = (marble: MarbleType) => { 28 | if (!isOutput && streamId) { 29 | emitOutput(streamId, marble); 30 | } 31 | }; 32 | 33 | const MarbleComponent = isOutput ? OutputMarble : StreamMarble; 34 | 35 | return ( 36 |
37 |
38 | 39 |
40 | {marbles?.map((marble) => ( 41 | marble && ( 42 | handleMarbleCrossLine(marble)} 46 | /> 47 | ) 48 | ))} 49 |
50 |
51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/components/Timeline/labels/MergeTimelineLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Plus } from 'lucide-react'; 3 | import { useMarbleStore } from '../../../store/marbleStore'; 4 | import { generateMergeMarble } from '../../../utils/marbleUtils'; 5 | 6 | interface TimelineLabelProps { 7 | label: string; 8 | streamId?: number; 9 | } 10 | 11 | export const MergeTimelineLabel: React.FC = ({ label, streamId }) => { 12 | const { addMarble, isStream3Enabled } = useMarbleStore(); 13 | 14 | const handleAddMarble = () => { 15 | if (!streamId) return; 16 | const marble = generateMergeMarble(streamId); 17 | addMarble(streamId, marble); 18 | }; 19 | 20 | const getButtonColor = (id?: number) => { 21 | switch (id) { 22 | case 1: 23 | return 'bg-blue-500 hover:bg-blue-600'; 24 | case 2: 25 | return 'bg-red-500 hover:bg-red-600'; 26 | case 3: 27 | return 'bg-purple hover:bg-purple-600'; 28 | default: 29 | return 'bg-gray-500 hover:bg-gray-600'; 30 | } 31 | }; 32 | 33 | if (streamId === 3 && !isStream3Enabled) { 34 | return null; 35 | } 36 | 37 | return ( 38 |
39 | {streamId && ( 40 | 47 | )} 48 | {label} 49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/Timeline/labels/CombineLatestTimelineLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Plus } from 'lucide-react'; 3 | import { useMarbleStore } from '../../../store/marbleStore'; 4 | import { generateCombineLatestMarble } from '../../../utils/marbleUtils'; 5 | 6 | interface TimelineLabelProps { 7 | label: string; 8 | streamId?: number; 9 | } 10 | 11 | export const CombineLatestTimelineLabel: React.FC = ({ label, streamId }) => { 12 | const { addMarble, isStream3Enabled } = useMarbleStore(); 13 | 14 | const handleAddMarble = () => { 15 | if (!streamId) return; 16 | const marble = generateCombineLatestMarble(streamId); 17 | addMarble(streamId, marble); 18 | }; 19 | 20 | const getButtonColor = (id?: number) => { 21 | switch (id) { 22 | case 1: 23 | return 'bg-blue-500 hover:bg-blue-600'; 24 | case 2: 25 | return 'bg-red-500 hover:bg-red-600'; 26 | case 3: 27 | return 'bg-purple hover:bg-purple-600'; 28 | default: 29 | return 'bg-gray-500 hover:bg-gray-600'; 30 | } 31 | }; 32 | 33 | if (streamId === 3 && !isStream3Enabled) { 34 | return null; 35 | } 36 | 37 | return ( 38 |
39 | {streamId && ( 40 | 47 | )} 48 | {label} 49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/Timeline/StreamMarble.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { motion, useAnimation } from 'framer-motion'; 3 | import { useMarbleStore } from '../../store/marbleStore'; 4 | import type { Marble as MarbleType } from '../../types/marble'; 5 | 6 | interface StreamMarbleProps { 7 | marble: MarbleType; 8 | onCrossLine?: () => void; 9 | } 10 | 11 | export const StreamMarble: React.FC = ({ marble, onCrossLine }) => { 12 | const { speed } = useMarbleStore(); 13 | const controls = useAnimation(); 14 | 15 | useEffect(() => { 16 | let hasCrossedLine = false; 17 | const LINE_POSITION = 0.35; // 35% - matches the line position in App.tsx 18 | const TOTAL_DURATION = 3.2; // Base duration in seconds 19 | 20 | const animate = async () => { 21 | await controls.start({ 22 | left: "100%", 23 | transition: { 24 | duration: TOTAL_DURATION / speed, 25 | ease: "linear" 26 | }, 27 | onUpdate: (latest) => { 28 | if (!hasCrossedLine && latest >= LINE_POSITION) { 29 | hasCrossedLine = true; 30 | onCrossLine?.(); 31 | } 32 | } 33 | }); 34 | }; 35 | 36 | animate(); 37 | 38 | return () => { 39 | controls.stop(); 40 | }; 41 | }, [controls, speed, onCrossLine]); 42 | 43 | return ( 44 | 49 |
53 | {marble.value} 54 |
55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/store/operators/mapStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { BaseOperatorState, createNewSubjects } from './baseOperatorStore'; 3 | import { resetMapIndexes } from '../../utils/marbleUtils'; 4 | 5 | export const useMapStore = create((set) => ({ 6 | stream1Marbles: [], 7 | stream2Marbles: [], 8 | stream3Marbles: [], 9 | outputMarbles: [], 10 | ...createNewSubjects(), 11 | isStream3Enabled: false, // Always false for map 12 | speed: 1, 13 | 14 | addMarble: (streamId, marble) => { 15 | if (streamId !== 1) return; // Only allow Stream 1 marbles 16 | 17 | set((state) => { 18 | const newMarble = { 19 | ...marble, 20 | timestamp: Date.now(), 21 | id: marble.id || Math.random().toString(36).substr(2) 22 | }; 23 | 24 | state.stream1$.next(newMarble); 25 | return { stream1Marbles: [...state.stream1Marbles, newMarble] }; 26 | }); 27 | }, 28 | 29 | addOutputMarble: (marble) => set((state) => ({ 30 | outputMarbles: [...state.outputMarbles, marble] 31 | })), 32 | 33 | setSpeed: (speed) => set({ speed }), 34 | 35 | clearMarbles: () => { 36 | resetMapIndexes(); 37 | set({ 38 | stream1Marbles: [], 39 | stream2Marbles: [], 40 | stream3Marbles: [], 41 | outputMarbles: [] 42 | }); 43 | }, 44 | 45 | resetPipeline: () => { 46 | set((state) => { 47 | state.stream1$.complete(); 48 | state.stream2$.complete(); 49 | state.stream3$.complete(); 50 | resetMapIndexes(); 51 | return { 52 | ...createNewSubjects(), 53 | stream1Marbles: [], 54 | stream2Marbles: [], 55 | stream3Marbles: [], 56 | outputMarbles: [] 57 | }; 58 | }); 59 | }, 60 | 61 | // Remove toggleStream3 since Map only uses Stream 1 62 | toggleStream3: () => set((state) => state) // No-op 63 | })); -------------------------------------------------------------------------------- /src/store/operators/scanStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { BaseOperatorState, createNewSubjects } from './baseOperatorStore'; 3 | import { resetScanIndexes } from '../../utils/marbleUtils'; 4 | 5 | export const useScanStore = create((set) => ({ 6 | stream1Marbles: [], 7 | stream2Marbles: [], 8 | stream3Marbles: [], 9 | outputMarbles: [], 10 | ...createNewSubjects(), 11 | isStream3Enabled: false, // Always false for scan 12 | speed: 1, 13 | 14 | addMarble: (streamId, marble) => { 15 | if (streamId !== 1) return; // Only allow Stream 1 marbles 16 | 17 | set((state) => { 18 | const newMarble = { 19 | ...marble, 20 | timestamp: Date.now(), 21 | id: marble.id || Math.random().toString(36).substr(2) 22 | }; 23 | 24 | state.stream1$.next(newMarble); 25 | return { stream1Marbles: [...state.stream1Marbles, newMarble] }; 26 | }); 27 | }, 28 | 29 | addOutputMarble: (marble) => set((state) => ({ 30 | outputMarbles: [...state.outputMarbles, marble] 31 | })), 32 | 33 | setSpeed: (speed) => set({ speed }), 34 | 35 | clearMarbles: () => { 36 | resetScanIndexes(); 37 | set({ 38 | stream1Marbles: [], 39 | stream2Marbles: [], 40 | stream3Marbles: [], 41 | outputMarbles: [] 42 | }); 43 | }, 44 | 45 | resetPipeline: () => { 46 | set((state) => { 47 | state.stream1$.complete(); 48 | state.stream2$.complete(); 49 | state.stream3$.complete(); 50 | resetScanIndexes(); 51 | return { 52 | ...createNewSubjects(), 53 | stream1Marbles: [], 54 | stream2Marbles: [], 55 | stream3Marbles: [], 56 | outputMarbles: [] 57 | }; 58 | }); 59 | }, 60 | 61 | // Remove toggleStream3 since Scan only uses Stream 1 62 | toggleStream3: () => set((state) => state) // No-op 63 | })); -------------------------------------------------------------------------------- /src/store/operators/filterStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { BaseOperatorState, createNewSubjects } from './baseOperatorStore'; 3 | import { resetFilterIndexes } from '../../utils/marbleUtils'; 4 | 5 | export const useFilterStore = create((set) => ({ 6 | stream1Marbles: [], 7 | stream2Marbles: [], 8 | stream3Marbles: [], 9 | outputMarbles: [], 10 | ...createNewSubjects(), 11 | isStream3Enabled: false, // Always false for filter 12 | speed: 1, 13 | 14 | addMarble: (streamId, marble) => { 15 | if (streamId !== 1) return; // Only allow Stream 1 marbles 16 | 17 | set((state) => { 18 | const newMarble = { 19 | ...marble, 20 | timestamp: Date.now(), 21 | id: marble.id || Math.random().toString(36).substr(2) 22 | }; 23 | 24 | state.stream1$.next(newMarble); 25 | return { stream1Marbles: [...state.stream1Marbles, newMarble] }; 26 | }); 27 | }, 28 | 29 | addOutputMarble: (marble) => set((state) => ({ 30 | outputMarbles: [...state.outputMarbles, marble] 31 | })), 32 | 33 | setSpeed: (speed) => set({ speed }), 34 | 35 | clearMarbles: () => { 36 | resetFilterIndexes(); 37 | set({ 38 | stream1Marbles: [], 39 | stream2Marbles: [], 40 | stream3Marbles: [], 41 | outputMarbles: [] 42 | }); 43 | }, 44 | 45 | resetPipeline: () => { 46 | set((state) => { 47 | state.stream1$.complete(); 48 | state.stream2$.complete(); 49 | state.stream3$.complete(); 50 | resetFilterIndexes(); 51 | return { 52 | ...createNewSubjects(), 53 | stream1Marbles: [], 54 | stream2Marbles: [], 55 | stream3Marbles: [], 56 | outputMarbles: [] 57 | }; 58 | }); 59 | }, 60 | 61 | // Remove toggleStream3 since Filter only uses Stream 1 62 | toggleStream3: () => set((state) => state) // No-op 63 | })); -------------------------------------------------------------------------------- /src/components/Timeline/Marble.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { motion, useAnimation } from 'framer-motion'; 3 | import { useMarbleStore } from '../../store/marbleStore'; 4 | import type { Marble as MarbleType } from '../../types/marble'; 5 | 6 | interface MarbleProps { 7 | marble: MarbleType; 8 | onCrossLine?: () => void; 9 | } 10 | 11 | export const Marble: React.FC = ({ marble, onCrossLine }) => { 12 | const { speed } = useMarbleStore(); 13 | const controls = useAnimation(); 14 | const displayValue = marble.sourceValues 15 | ? `${marble.sourceValues.stream1}${marble.sourceValues.stream2}${marble.sourceValues.stream3}` 16 | : marble.value; 17 | 18 | useEffect(() => { 19 | let hasCrossedLine = false; 20 | const LINE_POSITION = 0.35; // 35% - matches the line position in App.tsx 21 | const TOTAL_DURATION = 3.2; // Base duration in seconds 22 | 23 | const animate = async () => { 24 | await controls.start({ 25 | left: "100%", 26 | transition: { 27 | duration: TOTAL_DURATION / speed, 28 | ease: "linear" 29 | }, 30 | onUpdate: (latest) => { 31 | if (!hasCrossedLine && latest >= LINE_POSITION) { 32 | hasCrossedLine = true; 33 | onCrossLine?.(); 34 | } 35 | } 36 | }); 37 | }; 38 | 39 | animate(); 40 | 41 | return () => { 42 | controls.stop(); 43 | }; 44 | }, [controls, speed, onCrossLine]); 45 | 46 | return ( 47 | 52 |
56 | {displayValue} 57 |
58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/hooks/operators/useScanStream.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { scan } from 'rxjs'; 3 | import { useScanStore } from '../../store/operators/scanStore'; 4 | 5 | export const useScanStream = () => { 6 | const { 7 | stream1$, 8 | addOutputMarble 9 | } = useScanStore(); 10 | 11 | useEffect(() => { 12 | let isFirstValue = true; 13 | 14 | const subscription = stream1$.pipe( 15 | scan((acc, marble) => { 16 | // For first value, if no seed is provided 17 | // we use the first value as initial state but with green color 18 | if (isFirstValue) { 19 | isFirstValue = false; 20 | return { 21 | ...marble, 22 | id: `${marble.id}-scanned`, 23 | color: 'rgb(34, 197, 94)' // Always green for output 24 | }; 25 | } 26 | 27 | // For subsequent values, accumulate using the operator 28 | try { 29 | return { 30 | ...marble, 31 | id: `${marble.id}-scanned`, 32 | value: typeof acc.value === 'number' && typeof marble.value === 'number' 33 | ? acc.value + marble.value 34 | : marble.value, 35 | color: 'rgb(34, 197, 94)' // Always green for output 36 | }; 37 | } catch (error) { 38 | // If accumulator throws, the process ends 39 | throw error; 40 | } 41 | }) 42 | ).subscribe({ 43 | next: marble => { 44 | if (marble && marble.value !== undefined && marble.value !== null) { 45 | addOutputMarble({ 46 | ...marble, 47 | timestamp: Date.now(), 48 | id: marble.id || Math.random().toString(36).substr(2), 49 | }); 50 | } 51 | }, 52 | error: (error) => { 53 | console.error('Scan operator error:', error); 54 | } 55 | }); 56 | 57 | return () => subscription.unsubscribe(); 58 | }, [stream1$, addOutputMarble]); 59 | }; -------------------------------------------------------------------------------- /src/components/Controls.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { RotateCcw, Plus, Minus } from 'lucide-react'; 3 | import { useMarbleStore } from '../store/marbleStore'; 4 | import { SpeedControl } from './SpeedControl'; 5 | import type { RxJSOperator } from '../types/marble'; 6 | 7 | const operators: RxJSOperator[] = ['map', 'filter', 'merge', 'combineLatest', 'scan']; 8 | 9 | const multiStreamOperators = ['merge', 'combineLatest']; 10 | 11 | export const Controls: React.FC = () => { 12 | const { 13 | currentOperator, 14 | setOperator, 15 | resetPipeline, 16 | isStream3Enabled, 17 | toggleStream3 18 | } = useMarbleStore(); 19 | 20 | // Only show Stream 3 toggle for operators that support it 21 | const showStream3Toggle = multiStreamOperators.includes(currentOperator); 22 | 23 | return ( 24 |
25 |
26 |
27 | {showStream3Toggle && ( 28 | 35 | )} 36 | 37 | 44 | 45 | 46 |
47 | 48 | 59 |
60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /src/store/operators/concatStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { BaseOperatorState, createNewSubjects } from './baseOperatorStore'; 3 | import { resetConcatIndexes } from '../../utils/marbleUtils'; 4 | 5 | export const useConcatStore = create((set) => ({ 6 | stream1Marbles: [], 7 | stream2Marbles: [], 8 | stream3Marbles: [], 9 | outputMarbles: [], 10 | ...createNewSubjects(), 11 | isStream3Enabled: false, 12 | speed: 1, 13 | 14 | addMarble: (streamId, marble) => { 15 | if (!marble || streamId === 3) return; 16 | 17 | set((state) => { 18 | const newMarble = { 19 | ...marble, 20 | timestamp: Date.now(), 21 | id: marble.id || Math.random().toString(36).substr(2) 22 | }; 23 | 24 | switch (streamId) { 25 | case 1: 26 | state.stream1$.next(newMarble); 27 | return { stream1Marbles: [...state.stream1Marbles, newMarble] }; 28 | case 2: 29 | state.stream2$.next(newMarble); 30 | return { stream2Marbles: [...state.stream2Marbles, newMarble] }; 31 | default: 32 | return state; 33 | } 34 | }); 35 | }, 36 | 37 | addOutputMarble: (marble) => set((state) => ({ 38 | outputMarbles: [...state.outputMarbles, marble] 39 | })), 40 | 41 | setSpeed: (speed) => set({ speed }), 42 | 43 | clearMarbles: () => { 44 | resetConcatIndexes(); 45 | set({ 46 | stream1Marbles: [], 47 | stream2Marbles: [], 48 | stream3Marbles: [], 49 | outputMarbles: [] 50 | }); 51 | }, 52 | 53 | resetPipeline: () => { 54 | set((state) => { 55 | state.stream1$.complete(); 56 | state.stream2$.complete(); 57 | state.stream3$.complete(); 58 | resetConcatIndexes(); 59 | return { 60 | ...createNewSubjects(), 61 | stream1Marbles: [], 62 | stream2Marbles: [], 63 | stream3Marbles: [], 64 | outputMarbles: [] 65 | }; 66 | }); 67 | }, 68 | 69 | toggleStream3: () => set((state) => { 70 | state.stream1$.complete(); 71 | state.stream2$.complete(); 72 | state.stream3$.complete(); 73 | resetConcatIndexes(); 74 | return { 75 | ...createNewSubjects(), 76 | isStream3Enabled: false, 77 | stream1Marbles: [], 78 | stream2Marbles: [], 79 | stream3Marbles: [], 80 | outputMarbles: [] 81 | }; 82 | }) 83 | })); -------------------------------------------------------------------------------- /src/components/Timeline/TimelineLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Plus } from 'lucide-react'; 3 | import { useMarbleStore } from '../../store/marbleStore'; 4 | import { 5 | generateMapMarble, 6 | generateFilterMarble, 7 | generateMergeMarble, 8 | generateCombineLatestMarble, 9 | generateConcatMarble, 10 | generateScanMarble 11 | } from '../../utils/marbleUtils/index'; 12 | 13 | interface TimelineLabelProps { 14 | label: string; 15 | streamId?: number; 16 | } 17 | 18 | export const TimelineLabel: React.FC = ({ label, streamId }) => { 19 | const { addMarble, isStream3Enabled, currentOperator } = useMarbleStore(); 20 | 21 | const handleAddMarble = () => { 22 | if (!streamId) return; 23 | 24 | let marble; 25 | switch (currentOperator) { 26 | case 'map': 27 | marble = generateMapMarble(); 28 | break; 29 | case 'filter': 30 | marble = generateFilterMarble(); 31 | break; 32 | case 'merge': 33 | marble = generateMergeMarble(streamId); 34 | break; 35 | case 'combineLatest': 36 | marble = generateCombineLatestMarble(streamId); 37 | break; 38 | case 'concat': 39 | marble = generateConcatMarble(streamId); 40 | break; 41 | case 'scan': 42 | marble = generateScanMarble(); 43 | break; 44 | default: 45 | return; 46 | } 47 | 48 | addMarble(streamId, marble); 49 | }; 50 | 51 | const getButtonColor = (id?: number) => { 52 | switch (id) { 53 | case 1: 54 | return 'bg-blue-500 hover:bg-blue-600'; 55 | case 2: 56 | return 'bg-red-500 hover:bg-red-600'; 57 | case 3: 58 | return 'bg-purple-500 hover:bg-purple-600'; 59 | default: 60 | return 'bg-gray-500 hover:bg-gray-600'; 61 | } 62 | }; 63 | 64 | return ( 65 |
66 | {streamId && (streamId !== 3 || (streamId === 3 && isStream3Enabled)) && ( 67 | 74 | )} 75 | {label} 76 |
77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /src/store/operators/combineLatestStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { BaseOperatorState, createNewSubjects } from './baseOperatorStore'; 3 | import { resetIndexes } from '../../utils/marbleUtils'; 4 | 5 | export const useCombineLatestStore = create((set) => ({ 6 | stream1Marbles: [], 7 | stream2Marbles: [], 8 | stream3Marbles: [], 9 | outputMarbles: [], 10 | ...createNewSubjects(), 11 | isStream3Enabled: false, 12 | speed: 1, 13 | 14 | addMarble: (streamId, marble) => { 15 | set((state) => { 16 | const newMarble = { 17 | ...marble, 18 | timestamp: Date.now(), 19 | id: marble.id || Math.random().toString(36).substr(2) 20 | }; 21 | 22 | switch (streamId) { 23 | case 1: 24 | state.stream1$.next(newMarble); 25 | return { stream1Marbles: [...state.stream1Marbles, newMarble] }; 26 | case 2: 27 | state.stream2$.next(newMarble); 28 | return { stream2Marbles: [...state.stream2Marbles, newMarble] }; 29 | case 3: 30 | if (state.isStream3Enabled) { 31 | state.stream3$.next(newMarble); 32 | return { stream3Marbles: [...state.stream3Marbles, newMarble] }; 33 | } 34 | return state; 35 | default: 36 | return state; 37 | } 38 | }); 39 | }, 40 | 41 | addOutputMarble: (marble) => set((state) => ({ 42 | outputMarbles: [...state.outputMarbles, marble] 43 | })), 44 | 45 | setSpeed: (speed) => set({ speed }), 46 | 47 | clearMarbles: () => { 48 | resetIndexes(); 49 | set({ 50 | stream1Marbles: [], 51 | stream2Marbles: [], 52 | stream3Marbles: [], 53 | outputMarbles: [] 54 | }); 55 | }, 56 | 57 | resetPipeline: () => { 58 | set((state) => { 59 | state.stream1$.complete(); 60 | state.stream2$.complete(); 61 | state.stream3$.complete(); 62 | resetIndexes(); 63 | return { 64 | ...createNewSubjects(), 65 | stream1Marbles: [], 66 | stream2Marbles: [], 67 | stream3Marbles: [], 68 | outputMarbles: [] 69 | }; 70 | }); 71 | }, 72 | 73 | toggleStream3: () => set((state) => { 74 | state.stream1$.complete(); 75 | state.stream2$.complete(); 76 | state.stream3$.complete(); 77 | resetIndexes(); 78 | return { 79 | ...createNewSubjects(), 80 | isStream3Enabled: !state.isStream3Enabled, 81 | stream1Marbles: [], 82 | stream2Marbles: [], 83 | stream3Marbles: [], 84 | outputMarbles: [] 85 | }; 86 | }) 87 | })); -------------------------------------------------------------------------------- /src/store/operators/mergeStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { BaseOperatorState, createNewSubjects } from './baseOperatorStore'; 3 | import { resetMergeIndexes } from '../../utils/marbleUtils'; 4 | 5 | export const useMergeStore = create((set) => ({ 6 | stream1Marbles: [], 7 | stream2Marbles: [], 8 | stream3Marbles: [], 9 | outputMarbles: [], 10 | ...createNewSubjects(), 11 | isStream3Enabled: false, 12 | speed: 1, 13 | 14 | addMarble: (streamId, marble) => { 15 | if (!streamId) return; 16 | 17 | set((state) => { 18 | const newMarble = { 19 | ...marble, 20 | timestamp: Date.now(), 21 | id: marble.id || Math.random().toString(36).substr(2) 22 | }; 23 | 24 | switch (streamId) { 25 | case 1: 26 | state.stream1$.next(newMarble); 27 | return { stream1Marbles: [...state.stream1Marbles, newMarble] }; 28 | case 2: 29 | state.stream2$.next(newMarble); 30 | return { stream2Marbles: [...state.stream2Marbles, newMarble] }; 31 | case 3: 32 | if (state.isStream3Enabled) { 33 | state.stream3$.next(newMarble); 34 | return { stream3Marbles: [...state.stream3Marbles, newMarble] }; 35 | } 36 | return state; 37 | default: 38 | return state; 39 | } 40 | }); 41 | }, 42 | 43 | addOutputMarble: (marble) => set((state) => ({ 44 | outputMarbles: [...state.outputMarbles, marble] 45 | })), 46 | 47 | setSpeed: (speed) => set({ speed }), 48 | 49 | clearMarbles: () => { 50 | resetMergeIndexes(); 51 | set({ 52 | stream1Marbles: [], 53 | stream2Marbles: [], 54 | stream3Marbles: [], 55 | outputMarbles: [] 56 | }); 57 | }, 58 | 59 | resetPipeline: () => { 60 | set((state) => { 61 | state.stream1$.complete(); 62 | state.stream2$.complete(); 63 | state.stream3$.complete(); 64 | resetMergeIndexes(); 65 | return { 66 | ...createNewSubjects(), 67 | stream1Marbles: [], 68 | stream2Marbles: [], 69 | stream3Marbles: [], 70 | outputMarbles: [] 71 | }; 72 | }); 73 | }, 74 | 75 | toggleStream3: () => set((state) => { 76 | state.stream1$.complete(); 77 | state.stream2$.complete(); 78 | state.stream3$.complete(); 79 | resetMergeIndexes(); 80 | return { 81 | ...createNewSubjects(), 82 | isStream3Enabled: !state.isStream3Enabled, 83 | stream1Marbles: [], 84 | stream2Marbles: [], 85 | stream3Marbles: [], 86 | outputMarbles: [] 87 | }; 88 | }) 89 | })); -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Timeline } from './components/Timeline'; 3 | import { Controls } from './components/Controls'; 4 | import { OperatorDisplay } from './components/OperatorDisplay'; 5 | import { useMarbleStore } from './store/marbleStore'; 6 | import { useMarbleStreams } from './hooks/useMarbleStreams'; 7 | import { ThemeToggle } from "./components/ThemeToggle"; 8 | 9 | function App() { 10 | const { 11 | stream1Marbles, 12 | stream2Marbles, 13 | stream3Marbles, 14 | outputMarbles, 15 | currentOperator 16 | } = useMarbleStore(); 17 | 18 | // Initialize marble streams 19 | useMarbleStreams(); 20 | 21 | return ( 22 |
23 |
24 | 25 |
26 |
27 |
28 |
29 |

RxJS Marble Diagram Visualizer Beta V1.1

30 | Updated Feb 22, 2025 31 |
32 | *added Subscriber Boundary line to showcase Stream Emission vs. Output Emission 33 |
34 | 38 | Angular Space Banner 43 | 44 | 45 |
46 |
47 | {/* Vertical line with text */} 48 |
49 | {/* Purple line */} 50 |
54 | 55 | {/* Vertical text */} 56 |
57 |
61 | SUBSCRIBER BOUNDARY 62 |
63 |
64 |
65 | 66 | {/* Timeline container */} 67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
76 |
77 |
78 |
79 | 80 | 81 |
82 | ); 83 | } 84 | 85 | export default App; 86 | -------------------------------------------------------------------------------- /src/store/marbleStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { Subject } from 'rxjs'; 3 | import type { Marble, RxJSOperator } from '../types/marble'; 4 | import { resetIndexes } from '../utils/marbleUtils'; 5 | 6 | interface MarbleState { 7 | stream1Marbles: Marble[]; 8 | stream2Marbles: Marble[]; 9 | stream3Marbles: Marble[]; 10 | outputMarbles: Marble[]; 11 | currentOperator: RxJSOperator; 12 | stream1$: Subject; 13 | stream2$: Subject; 14 | stream3$: Subject; 15 | isStream3Enabled: boolean; 16 | speed: number; 17 | addMarble: (streamId: number, marble: Marble) => void; 18 | addOutputMarble: (marble: Marble) => void; 19 | emitOutput: (streamId: number, marble: Marble) => void; 20 | setOperator: (operator: RxJSOperator) => void; 21 | setSpeed: (speed: number) => void; 22 | clearMarbles: () => void; 23 | resetPipeline: () => void; 24 | toggleStream3: () => void; 25 | } 26 | 27 | const createNewSubjects = () => ({ 28 | stream1$: new Subject(), 29 | stream2$: new Subject(), 30 | stream3$: new Subject() 31 | }); 32 | 33 | const initialSubjects = createNewSubjects(); 34 | 35 | export const useMarbleStore = create((set) => ({ 36 | stream1Marbles: [], 37 | stream2Marbles: [], 38 | stream3Marbles: [], 39 | outputMarbles: [], 40 | currentOperator: 'map', 41 | ...initialSubjects, 42 | isStream3Enabled: false, 43 | speed: 1, 44 | 45 | addMarble: (streamId, marble) => { 46 | if (!marble || marble.value === undefined) return; 47 | 48 | set((state) => { 49 | const newMarble = { 50 | ...marble, 51 | timestamp: Date.now(), 52 | id: marble.id || Math.random().toString(36).substr(2) 53 | }; 54 | 55 | switch (streamId) { 56 | case 1: 57 | state.stream1$.next(newMarble); 58 | return { stream1Marbles: [...state.stream1Marbles, newMarble] }; 59 | case 2: 60 | state.stream2$.next(newMarble); 61 | return { stream2Marbles: [...state.stream2Marbles, newMarble] }; 62 | case 3: 63 | if (state.isStream3Enabled) { 64 | state.stream3$.next(newMarble); 65 | return { stream3Marbles: [...state.stream3Marbles, newMarble] }; 66 | } 67 | return state; 68 | default: 69 | return state; 70 | } 71 | }); 72 | }, 73 | 74 | addOutputMarble: (marble) => { 75 | if (!marble || marble.value === undefined) return; 76 | set((state) => ({ 77 | outputMarbles: [...state.outputMarbles, marble] 78 | })); 79 | }, 80 | 81 | emitOutput: (streamId, marble) => { 82 | set((state) => { 83 | switch (streamId) { 84 | case 1: 85 | state.stream1$.next(marble); 86 | break; 87 | case 2: 88 | state.stream2$.next(marble); 89 | break; 90 | case 3: 91 | if (state.isStream3Enabled) { 92 | state.stream3$.next(marble); 93 | } 94 | break; 95 | } 96 | return state; 97 | }); 98 | }, 99 | 100 | setOperator: (operator) => { 101 | set((state) => { 102 | state.stream1$.complete(); 103 | state.stream2$.complete(); 104 | state.stream3$.complete(); 105 | resetIndexes(); 106 | 107 | return { 108 | currentOperator: operator, 109 | outputMarbles: [], 110 | stream1Marbles: [], 111 | stream2Marbles: [], 112 | stream3Marbles: [], 113 | ...createNewSubjects() 114 | }; 115 | }); 116 | }, 117 | 118 | setSpeed: (speed) => set({ speed }), 119 | 120 | clearMarbles: () => { 121 | resetIndexes(); 122 | set({ 123 | stream1Marbles: [], 124 | stream2Marbles: [], 125 | stream3Marbles: [], 126 | outputMarbles: [] 127 | }); 128 | }, 129 | 130 | resetPipeline: () => { 131 | set((state) => { 132 | state.stream1$.complete(); 133 | state.stream2$.complete(); 134 | state.stream3$.complete(); 135 | resetIndexes(); 136 | 137 | return { 138 | ...createNewSubjects(), 139 | stream1Marbles: [], 140 | stream2Marbles: [], 141 | stream3Marbles: [], 142 | outputMarbles: [] 143 | }; 144 | }); 145 | }, 146 | 147 | toggleStream3: () => set((state) => { 148 | state.stream1$.complete(); 149 | state.stream2$.complete(); 150 | state.stream3$.complete(); 151 | resetIndexes(); 152 | 153 | return { 154 | ...createNewSubjects(), 155 | isStream3Enabled: !state.isStream3Enabled, 156 | stream1Marbles: [], 157 | stream2Marbles: [], 158 | stream3Marbles: [], 159 | outputMarbles: [] 160 | }; 161 | }) 162 | })); -------------------------------------------------------------------------------- /dist/assets/index-BLfRN02l.css: -------------------------------------------------------------------------------- 1 | *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}.container{width:100%}@media (min-width: 640px){.container{max-width:640px}}@media (min-width: 768px){.container{max-width:768px}}@media (min-width: 1024px){.container{max-width:1024px}}@media (min-width: 1280px){.container{max-width:1280px}}@media (min-width: 1536px){.container{max-width:1536px}}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.-left-32{left:-8rem}.bottom-0{bottom:0}.left-0{left:0}.left-\[35\%\]{left:35%}.right-0{right:0}.top-0{top:0}.top-1\/2{top:50%}.top-\[0px\]{top:0}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.z-\[9999\]{z-index:9999}.mx-auto{margin-left:auto;margin-right:auto}.mb-5{margin-bottom:1.25rem}.mt-1{margin-top:.25rem}.flex{display:flex}.h-0\.5{height:.125rem}.h-2{height:.5rem}.h-20{height:5rem}.h-24{height:6rem}.h-8{height:2rem}.h-auto{height:auto}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-0\.5{width:.125rem}.w-24{width:6rem}.w-8{width:2rem}.w-\[150px\]{width:150px}.w-full{width:100%}.min-w-12{min-width:3rem}.max-w-4xl{max-width:56rem}.flex-1{flex:1 1 0%}.-translate-x-1\/2{--tw-translate-x: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-x-4{--tw-translate-x: -1rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-rotate-180{--tw-rotate: -180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(2rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem * var(--tw-space-y-reverse))}.whitespace-nowrap{white-space:nowrap}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-t{border-top-width:1px}.border-gray-200{--tw-border-opacity: 1;border-color:rgb(229 231 235 / var(--tw-border-opacity))}.bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity))}.bg-gray-100{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity))}.bg-gray-200{--tw-bg-opacity: 1;background-color:rgb(229 231 235 / var(--tw-bg-opacity))}.bg-gray-300{--tw-bg-opacity: 1;background-color:rgb(209 213 219 / var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity))}.bg-gray-500{--tw-bg-opacity: 1;background-color:rgb(107 114 128 / var(--tw-bg-opacity))}.bg-purple-500{--tw-bg-opacity: 1;background-color:rgb(168 85 247 / var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity))}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-4{padding:1rem}.p-8{padding:2rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pb-32{padding-bottom:8rem}.pt-20{padding-top:5rem}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-\[10px\]{font-size:10px}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.font-bold{font-weight:700}.tracking-wide{letter-spacing:.025em}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity: 1;color:rgb(31 41 55 / var(--tw-text-opacity))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:bg-blue-600:hover{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity))}.hover\:bg-gray-100:hover{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity))}.hover\:bg-gray-50:hover{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity))}.hover\:bg-gray-600:hover{--tw-bg-opacity: 1;background-color:rgb(75 85 99 / var(--tw-bg-opacity))}.hover\:bg-purple-600:hover{--tw-bg-opacity: 1;background-color:rgb(147 51 234 / var(--tw-bg-opacity))}.hover\:bg-red-600:hover{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity))}@media (min-width: 640px){.sm\:-left-32{left:-8rem}.sm\:top-1\/2{top:50%}.sm\:-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}} 2 | --------------------------------------------------------------------------------