mirror of
https://github.com/morten-olsen/bob.git
synced 2026-02-08 01:46:29 +01:00
feat: init
This commit is contained in:
7
packages/playground/src/app.tsx
Normal file
7
packages/playground/src/app.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Page } from './containers/page';
|
||||
|
||||
const App: React.FC = () => {
|
||||
return <Page slug=".hello" />;
|
||||
};
|
||||
|
||||
export { App };
|
||||
51
packages/playground/src/containers/page.tsx
Normal file
51
packages/playground/src/containers/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { pages } from '../utils/pages';
|
||||
import { RunnerProvider } from '../features/runner';
|
||||
type PageProps = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
const Page: React.FC<PageProps> = ({ slug }) => {
|
||||
const [Component, setComponent] = useState<React.FC>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<unknown>();
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
const load = async () => {
|
||||
try {
|
||||
const page = pages.find((page) => page.slug === slug);
|
||||
if (!page) {
|
||||
throw new Error(`Page not found: ${slug}`);
|
||||
}
|
||||
const { default: Component } = (await page.loader()) as {
|
||||
default: React.FC;
|
||||
};
|
||||
setComponent(() => Component);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
}, [slug]);
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.toString()}</div>;
|
||||
}
|
||||
|
||||
if (loading || !Component) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<RunnerProvider>
|
||||
<Component />
|
||||
</RunnerProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export { Page };
|
||||
98
packages/playground/src/features/runner/block.tsx
Normal file
98
packages/playground/src/features/runner/block.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { RunnerContext } from './context';
|
||||
|
||||
type BlockProps = {
|
||||
worker: Worker;
|
||||
action: string;
|
||||
presenter?: React.FC<any>;
|
||||
};
|
||||
|
||||
const id = (function* () {
|
||||
let i = 0;
|
||||
while (true) {
|
||||
yield i++;
|
||||
}
|
||||
})();
|
||||
|
||||
const Block: React.FC<BlockProps> = ({
|
||||
worker,
|
||||
action,
|
||||
presenter: Presenter,
|
||||
}) => {
|
||||
const currentId = useRef(id.next().value);
|
||||
const { vars } = useContext(RunnerContext);
|
||||
const [output, setOutput] = useState<unknown>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const [running, setRunning] = useState<boolean>();
|
||||
const [duration, setDuration] = useState<number>();
|
||||
|
||||
const view = useMemo(() => {
|
||||
if (error) {
|
||||
return error.toString();
|
||||
}
|
||||
if (Presenter) {
|
||||
return <Presenter output={output} />;
|
||||
}
|
||||
return JSON.stringify(output, null, 2);
|
||||
}, [output, error, Presenter]);
|
||||
|
||||
const runBlock = useCallback(async () => {
|
||||
setRunning(true);
|
||||
setError(undefined);
|
||||
setOutput(undefined);
|
||||
|
||||
try {
|
||||
worker.postMessage({
|
||||
type: 'run',
|
||||
action,
|
||||
vars,
|
||||
id: currentId.current,
|
||||
});
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
|
||||
setRunning(false);
|
||||
}, [worker, vars, action]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: MessageEvent) => {
|
||||
const { type, payload, id, duration } = event.data;
|
||||
if (id !== currentId.current) {
|
||||
return;
|
||||
}
|
||||
setDuration(duration);
|
||||
setRunning(false);
|
||||
if (type === 'output') {
|
||||
setOutput(payload);
|
||||
}
|
||||
if (type === 'error') {
|
||||
setError(payload);
|
||||
}
|
||||
};
|
||||
worker.addEventListener('message', listener);
|
||||
return () => {
|
||||
worker.removeEventListener('message', listener);
|
||||
};
|
||||
}, [worker]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={runBlock} disabled={running}>
|
||||
Run
|
||||
</button>
|
||||
{duration && <div>Duration: {duration.toFixed(2)}ms</div>}
|
||||
{running && <div>Running...</div>}
|
||||
{view}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Block };
|
||||
42
packages/playground/src/features/runner/context.tsx
Normal file
42
packages/playground/src/features/runner/context.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createContext, useCallback, useMemo } from 'react';
|
||||
|
||||
type Vars = Record<string, unknown>;
|
||||
|
||||
type RunnerContextValue = {
|
||||
vars: Vars;
|
||||
run: (fn: (vars: Vars) => Promise<void>) => Promise<void>;
|
||||
};
|
||||
|
||||
type RunnerProviderProps = {
|
||||
vars?: Vars;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const RunnerContext = createContext<RunnerContextValue>({
|
||||
vars: {},
|
||||
run: async () => {},
|
||||
});
|
||||
|
||||
const RunnerProvider: React.FC<RunnerProviderProps> = ({
|
||||
vars = {},
|
||||
children,
|
||||
}) => {
|
||||
const currentVars = useMemo(() => vars, [vars]);
|
||||
|
||||
const run = useCallback(
|
||||
async (fn: (vars: Vars) => Promise<void>) => {
|
||||
const output = await fn(currentVars);
|
||||
return output;
|
||||
},
|
||||
[currentVars],
|
||||
);
|
||||
|
||||
return (
|
||||
<RunnerContext.Provider value={{ vars, run }}>
|
||||
{children}
|
||||
</RunnerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type { Vars };
|
||||
export { RunnerContext, RunnerProvider };
|
||||
3
packages/playground/src/features/runner/index.ts
Normal file
3
packages/playground/src/features/runner/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { RunnerProvider } from './context';
|
||||
export { Block } from './block';
|
||||
export { createWorker } from './worker';
|
||||
21
packages/playground/src/features/runner/worker.ts
Normal file
21
packages/playground/src/features/runner/worker.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
type WorkerFn = Record<string, (...args: any[]) => any>;
|
||||
|
||||
const createWorker = (fn: WorkerFn) => {
|
||||
self.addEventListener('message', (event) => {
|
||||
const { action, vars = {}, id } = event.data;
|
||||
const run = async () => {
|
||||
const startTime = performance.now();
|
||||
try {
|
||||
const result = await fn[action](vars);
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
self.postMessage({ type: 'output', payload: result, id, duration });
|
||||
} catch (error) {
|
||||
self.postMessage({ type: 'error', payload: error, id });
|
||||
}
|
||||
};
|
||||
run();
|
||||
});
|
||||
};
|
||||
|
||||
export { createWorker };
|
||||
9
packages/playground/src/main.tsx
Normal file
9
packages/playground/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './app.tsx';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
7
packages/playground/src/pages/hello/index.mdx
Normal file
7
packages/playground/src/pages/hello/index.mdx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Block } from '../../features/runner'
|
||||
import { worker } from './worker';
|
||||
import { Presenter } from '../../presenters/graph';
|
||||
|
||||
# Hello World
|
||||
|
||||
<Block worker={worker} action="realistic" presenter={Presenter} />
|
||||
148
packages/playground/src/pages/hello/script.ts
Normal file
148
packages/playground/src/pages/hello/script.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { calulation, plugins } from 'bob-the-algorithm';
|
||||
import { createWorker } from '../../features/runner/worker';
|
||||
import { convertResult } from '../../utils/graph';
|
||||
|
||||
const MIN = 1000 * 60;
|
||||
const HOUR = 1000 * 60 * 60;
|
||||
|
||||
const getTravelTime = async () => 30 * MIN;
|
||||
|
||||
const realistic = async () => {
|
||||
const result = await calulation({
|
||||
location: 'home',
|
||||
time: 0,
|
||||
heuristic: ({ completed }) => completed.length >= 3,
|
||||
plugins: [
|
||||
plugins.transport({
|
||||
getTravelTime,
|
||||
}),
|
||||
],
|
||||
planables: [
|
||||
{
|
||||
id: `Brush teeth`,
|
||||
duration: 2 * MIN,
|
||||
start: {
|
||||
min: 7 * HOUR,
|
||||
max: 8 * HOUR,
|
||||
},
|
||||
attributes: {
|
||||
locations: ['home'],
|
||||
},
|
||||
score: 1,
|
||||
},
|
||||
{
|
||||
id: 'Drop off kids',
|
||||
duration: 30 * MIN,
|
||||
attributes: {
|
||||
locations: ['daycare'],
|
||||
},
|
||||
score: 1,
|
||||
start: {
|
||||
min: 7 * HOUR,
|
||||
max: 9 * HOUR,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Pickup the kids',
|
||||
duration: 30 * MIN,
|
||||
attributes: {
|
||||
locations: ['daycare'],
|
||||
},
|
||||
score: 1,
|
||||
start: {
|
||||
min: 15 * HOUR,
|
||||
max: 15.5 * HOUR,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: `Eat breakfast`,
|
||||
duration: 15 * MIN,
|
||||
start: {
|
||||
min: 7 * HOUR,
|
||||
max: 9 * HOUR,
|
||||
},
|
||||
attributes: {
|
||||
locations: ['home'],
|
||||
},
|
||||
score: 1,
|
||||
},
|
||||
{
|
||||
id: 'Do work',
|
||||
duration: 1 * HOUR,
|
||||
count: 5,
|
||||
attributes: {
|
||||
locations: ['work'],
|
||||
},
|
||||
score: 10,
|
||||
start: {
|
||||
min: 8 * HOUR,
|
||||
max: 18 * HOUR,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Read book',
|
||||
duration: 0.5 * HOUR,
|
||||
attributes: {
|
||||
locations: ['home', 'work'],
|
||||
},
|
||||
score: 3,
|
||||
count: 2,
|
||||
start: {
|
||||
min: 8 * HOUR,
|
||||
max: 22 * HOUR,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Meditate',
|
||||
duration: 10 * MIN,
|
||||
score: 1,
|
||||
attributes: {},
|
||||
start: {
|
||||
min: 8 * HOUR,
|
||||
max: 22 * HOUR,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Meeting 1',
|
||||
duration: 1 * HOUR,
|
||||
attributes: {
|
||||
locations: ['work', 'work'],
|
||||
},
|
||||
score: 10,
|
||||
start: {
|
||||
min: 10 * HOUR,
|
||||
max: 10 * HOUR,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Meeting 2',
|
||||
duration: 1 * HOUR,
|
||||
attributes: {
|
||||
locations: ['work', 'work'],
|
||||
},
|
||||
score: 10,
|
||||
start: {
|
||||
min: 12 * HOUR,
|
||||
max: 12 * HOUR,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Play playstation',
|
||||
duration: 1 * HOUR,
|
||||
attributes: {
|
||||
locations: ['home'],
|
||||
},
|
||||
score: 10,
|
||||
start: {
|
||||
min: 16 * HOUR,
|
||||
max: 24 * HOUR,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return convertResult(result);
|
||||
};
|
||||
|
||||
createWorker({
|
||||
realistic,
|
||||
});
|
||||
5
packages/playground/src/pages/hello/worker.ts
Normal file
5
packages/playground/src/pages/hello/worker.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
const worker = new Worker(new URL('./script.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
});
|
||||
|
||||
export { worker };
|
||||
139
packages/playground/src/presenters/graph/index.tsx
Normal file
139
packages/playground/src/presenters/graph/index.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { GraphCanvas } from 'reagraph';
|
||||
import { ConvertedResult } from '../../utils/graph';
|
||||
import { Plan } from './plan';
|
||||
|
||||
type PresenterProps = {
|
||||
output: ConvertedResult;
|
||||
};
|
||||
|
||||
const Presenter: React.FC<PresenterProps> = ({ output }) => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [visualize, setVisualize] = useState(false);
|
||||
const [selectedNode, setSelectedNode] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const selectedPath = useMemo(() => {
|
||||
if (!selectedNode) {
|
||||
return [];
|
||||
}
|
||||
const result: string[] = [];
|
||||
let current = output.result.nodes.find((n) => n.id === selectedNode);
|
||||
|
||||
while (current) {
|
||||
result.push(current.id);
|
||||
if (!current.parent) {
|
||||
break;
|
||||
}
|
||||
current = output.result.nodes.find((n) => n.id === current?.parent);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [selectedNode, output]);
|
||||
const completed = useMemo(() => {
|
||||
return (
|
||||
output?.result?.completed
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
score: c.score,
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 10) || []
|
||||
);
|
||||
}, [output?.result?.completed]);
|
||||
|
||||
const maxStep = useMemo(
|
||||
() => Math.max(...(output?.nodes?.map((n) => n.data?.exploreId) || [])),
|
||||
[output],
|
||||
);
|
||||
const collapsedNodeIds = useMemo(
|
||||
() =>
|
||||
output?.nodes
|
||||
?.filter((n) => n.data?.exploreId > currentStep)
|
||||
.map((n) => n.id),
|
||||
[output, currentStep],
|
||||
);
|
||||
if (!output) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
Nodes count: {output.nodes.length}
|
||||
<button onClick={() => setVisualize(!visualize)}>
|
||||
{visualize ? 'Hide' : 'Show'} Visualize
|
||||
</button>
|
||||
{visualize && (
|
||||
<>
|
||||
<button onClick={() => setCurrentStep(currentStep - 1)}>Prev</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={maxStep}
|
||||
value={currentStep}
|
||||
onChange={(e) => setCurrentStep(parseInt(e.target.value))}
|
||||
/>
|
||||
<button onClick={() => setCurrentStep(currentStep + 1)}>Next</button>
|
||||
</>
|
||||
)}
|
||||
{completed.map((c) => (
|
||||
<div key={c.id} onClick={() => setSelectedNode(c.id)}>
|
||||
{c.id} - {c.score}
|
||||
</div>
|
||||
))}
|
||||
{selectedNode && <Plan id={selectedNode} output={output} />}
|
||||
{visualize && (
|
||||
<div style={{ position: 'relative', height: '70vh' }}>
|
||||
<GraphCanvas
|
||||
{...output}
|
||||
collapsedNodeIds={collapsedNodeIds}
|
||||
labelType="all"
|
||||
onNodeClick={(node) => {
|
||||
if (node.id === selectedNode) {
|
||||
setSelectedNode(undefined);
|
||||
return;
|
||||
}
|
||||
setSelectedNode(node.id);
|
||||
}}
|
||||
selections={selectedPath}
|
||||
renderNode={({ size, opacity, node }) => {
|
||||
let color = 'gray';
|
||||
if (
|
||||
node.data?.exploreId < currentStep &&
|
||||
node.data?.exploreId > 0
|
||||
) {
|
||||
color = 'yellow';
|
||||
}
|
||||
if (node.data?.exploreId === currentStep) {
|
||||
color = 'blue';
|
||||
}
|
||||
if (node.data?.deadEnd) {
|
||||
color = 'red';
|
||||
}
|
||||
if (node.data?.completed) {
|
||||
color = 'green';
|
||||
}
|
||||
if (node.data?.type === 'root') {
|
||||
color = 'black';
|
||||
}
|
||||
return (
|
||||
<group>
|
||||
<mesh>
|
||||
<circleGeometry attach="geometry" args={[size]} />
|
||||
<meshBasicMaterial
|
||||
attach="material"
|
||||
color={color}
|
||||
opacity={opacity}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { Presenter };
|
||||
76
packages/playground/src/presenters/graph/plan.tsx
Normal file
76
packages/playground/src/presenters/graph/plan.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { GraphNode } from 'bob-the-algorithm';
|
||||
import { useMemo } from 'react';
|
||||
import { ConvertedResult } from '../../utils/graph';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
type PlanProps = {
|
||||
id: string;
|
||||
output: ConvertedResult;
|
||||
};
|
||||
|
||||
type NodeProps = {
|
||||
node: GraphNode;
|
||||
output: ConvertedResult;
|
||||
};
|
||||
|
||||
const Node = ({ node, output }: NodeProps) => {
|
||||
const planable = useMemo(() => {
|
||||
return node.planable
|
||||
? output.result.planables.find((n) => n.id === node.planable)
|
||||
: null;
|
||||
}, [node, output]);
|
||||
|
||||
const time = useMemo(() => {
|
||||
const start = new Date(node.time);
|
||||
const end = new Date(start.getTime() + node.duration);
|
||||
return (
|
||||
<span>
|
||||
{format(start, 'HH:mm')} - {format(end, 'HH:mm')}
|
||||
</span>
|
||||
);
|
||||
}, [node.duration, node.time]);
|
||||
|
||||
if (planable) {
|
||||
return (
|
||||
<div>
|
||||
{time} Planable: {planable!.id}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (node.type === 'travel') {
|
||||
return (
|
||||
<div>
|
||||
{time} Travel: {node.location}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const Plan: React.FC<PlanProps> = ({ id, output }) => {
|
||||
const nodes = useMemo(() => {
|
||||
const result: GraphNode[] = [];
|
||||
let current = output.result.nodes.find((n) => n.id === id);
|
||||
|
||||
while (current) {
|
||||
result.push(current);
|
||||
if (!current.parent) {
|
||||
break;
|
||||
}
|
||||
current = output.result.nodes.find((n) => n.id === current?.parent);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [id, output]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{nodes.map((n) => (
|
||||
<Node key={n.id} node={n} output={output} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { Plan };
|
||||
49
packages/playground/src/utils/graph.ts
Normal file
49
packages/playground/src/utils/graph.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { CalulationResult } from 'bob-the-algorithm';
|
||||
|
||||
function msToHMS(ms: number) {
|
||||
// 1- Convert to seconds:
|
||||
let seconds = ms / 1000;
|
||||
// 2- Extract hours:
|
||||
const hours = seconds / 3600; // 3,600 seconds in 1 hour
|
||||
seconds = seconds % 3600; // seconds remaining after extracting hours
|
||||
// 3- Extract minutes:
|
||||
const minutes = seconds / 60; // 60 seconds in 1 minute
|
||||
// 4- Keep only seconds not extracted to minutes:
|
||||
seconds = seconds % 60;
|
||||
return hours + ':' + minutes + ':' + seconds;
|
||||
}
|
||||
const convertResult = (result: CalulationResult<any>) => {
|
||||
const nodes = result.nodes.map((node) => {
|
||||
let label = `root (${node.location})`;
|
||||
if (node.type === 'planable') {
|
||||
label = `task: ${node.planable!.toString()}`;
|
||||
} else if (node.type === 'travel') {
|
||||
label = `travel->${node.location}`;
|
||||
}
|
||||
return {
|
||||
id: node.id,
|
||||
label: `${msToHMS(node.time)}: ${label}`,
|
||||
data: {
|
||||
type: node.type,
|
||||
exploreId: node.exploreId,
|
||||
completed: node.completed,
|
||||
deadEnd: node.deadEnd,
|
||||
},
|
||||
};
|
||||
});
|
||||
const edges = result.nodes
|
||||
.filter((n) => n.parent)
|
||||
.map((node) => ({
|
||||
id: `${node.id}->${node.parent}`,
|
||||
source: node.parent!,
|
||||
target: node.id,
|
||||
label: node.score.toFixed(2),
|
||||
}));
|
||||
|
||||
return { nodes, edges, result };
|
||||
};
|
||||
|
||||
type ConvertedResult = ReturnType<typeof convertResult>;
|
||||
|
||||
export type { ConvertedResult };
|
||||
export { convertResult };
|
||||
8
packages/playground/src/utils/pages.ts
Normal file
8
packages/playground/src/utils/pages.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
const imports = import.meta.glob('../pages/*/index.mdx');
|
||||
|
||||
const pages = Object.entries(imports).map(([path, loader]) => {
|
||||
const slug = path.replace('./pages/', '').replace('/index.mdx', '');
|
||||
return { slug, loader };
|
||||
});
|
||||
|
||||
export { pages };
|
||||
1
packages/playground/src/vite-env.d.ts
vendored
Normal file
1
packages/playground/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user