├── 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 |
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 |
--------------------------------------------------------------------------------