4 Commits

Author SHA1 Message Date
Morten Olsen
5d8cba0ddd docs: improved webpage 2023-09-26 21:30:26 +02:00
Morten Olsen
c00b073d37 feat: add capabilities 2023-09-26 09:08:08 +02:00
Morten Olsen
a81afc9221 feat: improved plugin structure 2023-09-06 22:59:13 +02:00
Morten Olsen
89c0271fc5 ci: use pages artifact 2023-09-06 13:44:54 +02:00
64 changed files with 4498 additions and 772 deletions

View File

@@ -34,7 +34,7 @@ jobs:
pnpm install
pnpm run build
env:
ASSET_URL: https://mortenolsen.pro/bob
ASSET_URL: https://mortenolsen.pro/bob/
- uses: actions/upload-artifact@v3
with:
name: lib
@@ -44,12 +44,10 @@ jobs:
packages/*/package.json
package.json
README.md
- uses: actions/upload-artifact@v3
- name: Upload artifact
uses: actions/upload-pages-artifact@v2
with:
name: web
retention-days: 5
path: |
packages/playground/dist
path: './packages/playground/dist'
update-release-draft:
if: github.ref == 'refs/heads/main'
needs: build
@@ -76,11 +74,11 @@ jobs:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Setup Pages
uses: actions/configure-pages@v3
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v2
with:
artifact_name: web
release-npm:
if: github.ref == 'refs/heads/main'

2
.npmrc
View File

@@ -1,3 +1,3 @@
node-linker=hoisted
public-hoist-pattern[]=*@nextui-org/*
store-dir=.pnpm-store

View File

@@ -1,4 +1,4 @@
> bob-the-algorithm@ build /Users/alice/work/private/bob/packages/algorithm
> @bob-the-algorithm/core@0.1.8 build /home/alice/Git/bob/packages/algorithm
> tsc --build configs/tsconfig.libs.json

View File

@@ -25,5 +25,8 @@
"build": "tsc --build configs/tsconfig.libs.json"
},
"types": "./dist/cjs/types/index.d.ts",
"version": "0.1.8"
"version": "0.1.8",
"dependencies": {
"@sinclair/typebox": "^0.31.14"
}
}

View File

@@ -1,59 +1,77 @@
import { Attributes, GraphNode } from '../types/node';
import { GraphNode } from '../types/node';
import { Planable } from '../types/planable';
import { PluginAttributes, Plugins } from '../types/plugin';
import { PluginAttributes, PluginContext, Plugins } from '../types/plugin';
import { expandNode } from './expand-node';
type CalulationOptions<TPlugins extends Plugins> = {
location: string;
time: number;
planables: Planable<PluginAttributes<TPlugins>>[];
plugins: TPlugins;
};
type CalulationResult<TPlugins extends Plugins> = {
root: GraphNode<PluginAttributes<TPlugins>, PluginContext<TPlugins>>;
nodes: GraphNode<PluginAttributes<TPlugins>, PluginContext<TPlugins>>[];
completed: GraphNode<PluginAttributes<TPlugins>, PluginContext<TPlugins>>[];
planables: Planable<PluginAttributes<TPlugins>>[];
};
type RunOptions<TPlugins extends Plugins> = {
start: number;
context: PluginContext<TPlugins>;
planables: Planable<PluginAttributes<TPlugins>>[];
heuristic?: (result: any) => boolean;
onUpdated?: (result: any) => void;
};
type CalulationResult<TAttributes extends Attributes> = {
root: GraphNode<TAttributes>;
nodes: GraphNode<TAttributes>[];
completed: GraphNode<TAttributes>[];
planables: Planable<TAttributes>[];
};
type PlanableWithPlugins<TPlugins extends Plugins> = Planable<
PluginAttributes<TPlugins>
>;
const idGen = () => {
let id = 0;
return () => {
id += 1;
return id.toString();
class Bob<TPlugins extends Plugins> {
#options: CalulationOptions<TPlugins>;
#id: number = 0;
constructor(options: CalulationOptions<TPlugins>) {
this.#options = options;
}
#getNextId = (): string => {
return (this.#id++).toString();
};
};
const calulation = async <TPlugins extends Plugins>({
location,
time,
public run = async ({
planables,
plugins,
start,
context,
heuristic,
onUpdated,
}: CalulationOptions<TPlugins>): Promise<
CalulationResult<PluginAttributes<TPlugins>>
> => {
const generateId = idGen();
}: RunOptions<TPlugins>): Promise<CalulationResult<TPlugins>> => {
const { plugins } = this.#options;
let exploreId = 1;
const root: GraphNode<PluginAttributes<TPlugins>> = {
id: generateId(),
const root: GraphNode<
PluginAttributes<TPlugins>,
PluginContext<TPlugins>
> = {
id: this.#getNextId(),
context,
type: 'root',
score: 0,
parent: null,
duration: 0,
time,
location,
time: start,
exploreId: 0,
remaining: planables,
};
const nodes: GraphNode<PluginAttributes<TPlugins>>[] = [root];
const leafNodes: GraphNode<PluginAttributes<TPlugins>>[] = [root];
const completed: GraphNode<PluginAttributes<TPlugins>>[] = [];
const nodes: GraphNode<
PluginAttributes<TPlugins>,
PluginContext<TPlugins>
>[] = [root];
const leafNodes: GraphNode<
PluginAttributes<TPlugins>,
PluginContext<TPlugins>
>[] = [root];
const completed: GraphNode<
PluginAttributes<TPlugins>,
PluginContext<TPlugins>
>[] = [];
const popHighestScore = () => {
const highestScore = Math.max(...leafNodes.map((n) => n.score));
@@ -68,20 +86,22 @@ const calulation = async <TPlugins extends Plugins>({
while (leafNodes.length > 0) {
const node = popHighestScore();
node.exploreId = exploreId++;
const expanded = await expandNode({ node, generateId, plugins });
const expanded = await expandNode({
node,
generateId: this.#getNextId,
plugins,
});
nodes.push(...expanded);
completed.push(...expanded.filter((n) => n.remaining.length === 0));
leafNodes.push(...expanded.filter((n) => n.remaining.length > 0));
if (heuristic && heuristic({ root, nodes, completed, planables })) {
break;
}
if (onUpdated) {
onUpdated({ root, nodes, completed, planables });
}
}
return { root, nodes, completed, planables };
};
};
}
export type { CalulationOptions, CalulationResult };
export { calulation };
export type { CalulationOptions, CalulationResult, PlanableWithPlugins };
export { Bob };

View File

@@ -1,18 +1,26 @@
import { Attributes, GraphNode } from '../types/node';
import { Plugin } from '../types/plugin';
import { Plugins } from '../types/plugin';
import { hasImpossible } from './is-impossible';
type ExpandOptions<TAttributes extends Attributes> = {
node: GraphNode<TAttributes>;
type ExpandOptions<
TAttributes extends Attributes,
TContext extends Attributes,
> = {
node: GraphNode<TAttributes, TContext>;
generateId: () => string;
plugins: Plugin[];
plugins: Plugins;
};
const expandNode = async <TAttributes extends Attributes>({
const expandNode = async <
TAttributes extends Attributes,
TContext extends Attributes,
>({
node,
generateId,
plugins,
}: ExpandOptions<TAttributes>): Promise<GraphNode<TAttributes>[]> => {
}: ExpandOptions<TAttributes, TContext>): Promise<
GraphNode<TAttributes, TContext>[]
> => {
const isImpossible = hasImpossible({ node });
if (isImpossible) {
@@ -21,7 +29,7 @@ const expandNode = async <TAttributes extends Attributes>({
}
const metaNodes = await Promise.all(
plugins.map(async (plugin) => {
Object.values(plugins).map(async (plugin) => {
if (!plugin.getMetaNodes) {
return [];
}
@@ -39,7 +47,7 @@ const expandNode = async <TAttributes extends Attributes>({
);
const planables = node.remaining.filter((planable) => {
const hasNonPlanable = plugins.some(
const hasNonPlanable = Object.values(plugins).some(
(plugin) => plugin.isPlanable && !plugin.isPlanable(node, planable),
);
return !hasNonPlanable;
@@ -62,7 +70,7 @@ const expandNode = async <TAttributes extends Attributes>({
node.time + node.duration,
planable.start?.min || 0,
);
return {
const nextNode = {
...node,
type: 'planable',
exploreId: 0,
@@ -75,6 +83,14 @@ const expandNode = async <TAttributes extends Attributes>({
completed: remaining.length === 0,
parent: node.id,
};
return Object.values(plugins).reduce(
// TODO: remove any
(acc, plugin) =>
(plugin.mutateNode
? plugin.mutateNode(acc as any, planable)
: acc) as any,
nextNode,
) as any;
});
return [...planableNodes, ...metaNodes.flat()];

View File

@@ -1,5 +1,8 @@
export type { GraphNode } from './types/node';
export type { Planable } from './types/planable';
export { expandNode } from './algorithm/expand-node';
export { calulation, type CalulationResult } from './algorithm/calulation';
export { plugins } from './plugins/index';
export {
Bob,
type CalulationResult,
type PlanableWithPlugins,
} from './algorithm/calulation';
export { plugins, type AllPlugins } from './plugins';

View File

@@ -0,0 +1,57 @@
import { Type } from '@sinclair/typebox';
import { createPlugin } from './create';
const capabilities = () =>
createPlugin(
Type.Object({
capabilities: Type.Optional(
Type.Object({
provides: Type.Optional(Type.Array(Type.String())),
consumes: Type.Optional(Type.Array(Type.String())),
requires: Type.Optional(Type.Array(Type.String())),
perhibits: Type.Optional(Type.Array(Type.String())),
}),
),
}),
Type.Object({
capabilities: Type.Array(Type.String()),
}),
{
isPlanable: (node, planable) => {
const { requires = [], perhibits = [] } =
planable.attributes?.capabilities || {};
const capabilities = node.context.capabilities;
if (requires.length === 0 && perhibits.length === 0) {
return true;
}
const satisfiesRequire = requires.every((c) =>
capabilities.includes(c),
);
const satisfiesPerhibit = !perhibits.some((c) =>
capabilities.includes(c),
);
return satisfiesRequire && satisfiesPerhibit;
},
mutateNode: (node, planable) => {
const { provides = [], consumes = [] } =
planable.attributes?.capabilities || {};
const capabilities = node.context.capabilities || [];
const newCapabilities = [
...capabilities.filter((c) => !consumes.includes(c)),
...provides,
];
return {
...node,
context: {
...node.context,
capabilities: newCapabilities,
},
};
},
},
);
export { capabilities };

View File

@@ -0,0 +1,20 @@
import type { TSchema, Static } from '@sinclair/typebox';
import type { Plugin } from '../types/plugin';
const createPlugin = <
TAttribuesSchema extends TSchema,
TContextSchema extends TSchema,
>(
attributes: TAttribuesSchema,
context: TContextSchema,
plugin: Omit<
Plugin<Static<TAttribuesSchema>, Static<TContextSchema>>,
'attributes' | 'context'
>,
): Plugin<Static<TAttribuesSchema>, Static<TContextSchema>> => ({
...plugin,
attributes,
context,
});
export { createPlugin };

View File

@@ -1,9 +1,15 @@
import { Attributes } from '../types/node';
import { Plugin } from '../types/plugin';
import { transport } from './transport';
import { capabilities } from './capabilities';
const plugins = {
transport,
} satisfies Record<string, (...args: any[]) => Plugin<Attributes>>;
capabilities,
} satisfies Record<string, (...args: any[]) => Plugin>;
type AllPlugins = {
[K in keyof typeof plugins]: ReturnType<(typeof plugins)[K]>;
};
export type { AllPlugins };
export { plugins };

View File

@@ -1,5 +1,5 @@
import { GraphNode } from '../types/node';
import { Plugin } from '../types/plugin';
import { Type } from '@sinclair/typebox';
import { createPlugin } from './create';
type GetTravelTime = (from: string, to: string) => Promise<number>;
@@ -7,13 +7,15 @@ type TransportOptions = {
getTravelTime: GetTravelTime;
};
type TransportAttributes = {
locations?: string[];
};
const transport = ({
getTravelTime,
}: TransportOptions): Plugin<TransportAttributes> => ({
const transport = ({ getTravelTime }: TransportOptions) =>
createPlugin(
Type.Object({
locations: Type.Optional(Type.Array(Type.String())),
}),
Type.Object({
location: Type.String(),
}),
{
getMetaNodes: async (node) => {
const locations =
(node.type !== 'travel' &&
@@ -24,28 +26,33 @@ const transport = ({
.flat(),
),
]
.filter((location) => location !== node.location)
.filter((location) => location !== node.context.location)
.filter(Boolean)
.map((l) => l!)) ||
[];
const travelNodes = await Promise.all(
locations.map<Promise<GraphNode<TransportAttributes>>>(
async (location) => {
const travelTime = await getTravelTime(node.location, location);
locations.map(async (location) => {
const travelTime = await getTravelTime(
node.context.location,
location,
);
return {
...node,
type: 'travel',
type: 'travel' as const,
context: {
...node.context,
location,
},
planable: undefined,
location,
exploreId: 0,
score: node.score - 20,
score: node.score - 5,
time: node.time + node.duration,
duration: travelTime,
parent: node.id,
};
},
),
}),
);
return travelNodes;
@@ -53,12 +60,13 @@ const transport = ({
isPlanable: (node, planable) => {
if (
planable.attributes?.locations &&
!planable.attributes?.locations.includes(node.location)
!planable.attributes?.locations.includes(node.context.location)
) {
return false;
}
return true;
},
});
},
);
export { transport };

View File

@@ -2,11 +2,14 @@ import type { Planable } from './planable';
type Attributes = any;
type GraphNode<TAttributes extends Attributes = Attributes> = {
type GraphNode<
TAttributes extends Attributes = Attributes,
TContext extends Attributes = Attributes,
> = {
id: string;
type: 'root' | 'planable' | 'travel';
score: number;
location: string;
context: TContext;
parent: string | null;
time: number;
exploreId: number;

View File

@@ -11,7 +11,6 @@ type Planable<TAttributes extends Attributes = Attributes> = {
};
attributes: TAttributes;
required?: boolean;
locations?: string[];
};
export type { Planable };

View File

@@ -1,25 +1,40 @@
import { Attributes, GraphNode } from './node';
import { Expand, UnionToIntersection } from '../types/utils';
import { GraphNode } from './node';
import { Planable } from './planable';
type Plugin<TAttributes extends Attributes = Attributes> = {
type Plugin<TAttributes = any, TContext = any> = {
context: any;
attributes: any;
getMetaNodes?: (
node: GraphNode<TAttributes>,
) => Promise<GraphNode<TAttributes>[]>;
isImpossible?: (node: GraphNode<TAttributes>) => Promise<boolean>;
node: GraphNode<TAttributes, TContext>,
) => Promise<GraphNode<TAttributes, TContext>[]>;
isImpossible?: (node: GraphNode<TAttributes, TContext>) => Promise<boolean>;
isPlanable?: (
node: GraphNode<TAttributes>,
node: GraphNode<TAttributes, TContext>,
planable: Planable<TAttributes>,
) => boolean;
mutateNode?: (
node: GraphNode<TAttributes, TContext>,
planable: Planable<TAttributes>,
) => GraphNode<TAttributes, TContext>;
};
type Plugins = Plugin[];
type Plugins = Record<string, Plugin>;
type PluginAttributes<TPlugins extends Plugins> = {
[K in keyof TPlugins]: TPlugins[K] extends Plugin<infer TAttributes>
? TAttributes extends Attributes
type PluginAttributes<TPlugins extends Plugins> = MergeRecords<{
[K in keyof TPlugins]: TPlugins[K] extends Plugin<infer TAttributes, any>
? TAttributes
: never
: never;
}[number];
}>;
export type { Plugin, Plugins, PluginAttributes };
type MergeRecords<T extends Record<string, any>> = Expand<
UnionToIntersection<T[keyof T]>
>;
type PluginContext<TPlugins extends Plugins> = MergeRecords<{
[K in keyof TPlugins]: TPlugins[K] extends Plugin<any, infer TContext>
? TContext
: never;
}>;
export type { Plugin, Plugins, PluginAttributes, PluginContext };

View File

@@ -0,0 +1,17 @@
// expands object types one level deep
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
// expands object types recursively
type ExpandRecursively<T> = T extends object
? T extends infer O
? { [K in keyof O]: ExpandRecursively<O[K]> }
: never
: T;
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never;
export type { Expand, ExpandRecursively, UnionToIntersection };

View File

@@ -1,8 +1,12 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM"],
"lib": [
"ES2022",
"DOM"
],
"target": "ES2022",
"module": "ESNext"
"module": "ES2022",
"moduleResolution": "node"
}
}

View File

@@ -1,12 +1,12 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="/src/index.css" rel="stylesheet" />
<title>Vite + React + TS</title>
</head>
<body>
<body class="dark text-foreground bg-background">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@@ -11,9 +11,14 @@
},
"dependencies": {
"@bob-the-algorithm/core": "workspace:^",
"@nextui-org/react": "^2.1.13",
"date-fns": "^2.30.0",
"framer-motion": "^10.16.4",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-json-tree": "^0.18.0",
"react-router-dom": "^6.16.0",
"tailwindcss": "^3.3.3"
},
"devDependencies": {
"@mdx-js/rollup": "^2.3.0",
@@ -23,9 +28,11 @@
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.16",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"postcss": "^8.4.30",
"reagraph": "^4.13.0",
"typescript": "^5.0.2",
"vite": "^4.4.5"

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,7 +1,14 @@
import { Page } from './containers/page';
import { NextUIProvider } from '@nextui-org/react';
import { Router } from './router';
const App: React.FC = () => {
return <Page slug=".hello" />;
return (
<div className="dark text-foreground bg-background">
<NextUIProvider>
<Router />
</NextUIProvider>
</div>
);
};
export { App };

View File

@@ -0,0 +1,13 @@
type ContentProps = {
children: React.ReactNode;
};
const Content: React.FC<ContentProps> = ({ children }) => {
return (
<div className="max-w-3xl mx-auto w-full">
<div className="flex-1 p-8">{children}</div>
</div>
);
};
export { Content };

View File

@@ -0,0 +1,41 @@
import {
Navbar,
NavbarBrand,
NavbarContent,
NavbarItem,
Link,
} from '@nextui-org/react';
import { Link as RouterLink } from 'react-router-dom';
type FrameProps = {
children: React.ReactNode;
};
const Frame = ({ children }: FrameProps) => {
return (
<div className="h-screen flex flex-col">
<Navbar shouldHideOnScroll isBordered isBlurred>
<NavbarBrand>
<RouterLink to="/">
<p className="font-bold text-inherit">Bob</p>
</RouterLink>
</NavbarBrand>
<NavbarContent className="hidden sm:flex gap-4" justify="center">
<NavbarItem>
<RouterLink to="/">
<Link color="foreground">Home</Link>
</RouterLink>
</NavbarItem>
<NavbarItem>
<RouterLink to="/experiments">
<Link color="foreground">Experiments</Link>
</RouterLink>
</NavbarItem>
</NavbarContent>
</Navbar>
<div className="flex flex-col flex-1">{children}</div>
</div>
);
};
export { Frame };

View File

@@ -0,0 +1,97 @@
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalHeader,
useDisclosure,
Badge,
Checkbox,
Input,
} from '@nextui-org/react';
import { useMemo, useState } from 'react';
import { useExperimentResult } from '../../../features/experiment';
import { useSelectNode } from '../../../features/experiment/hooks';
import { GraphNode } from '@bob-the-algorithm/core';
const FindNodeView = () => {
const data = useExperimentResult();
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const [completed, setCompleted] = useState(true);
const [text, setText] = useState('');
const selectNode = useSelectNode();
const nodes = useMemo(() => {
if (!data) {
return [];
}
let result = [...data.nodes];
if (completed) {
result = result.filter((node) => node.completed);
}
if (text) {
result = result.filter((node) => node.id === text);
}
return result.sort((a, b) => b.score - a.score).slice(0, 10);
}, [data, completed, text]);
const getColor = (node: GraphNode) => {
if (node.completed) {
return 'success';
}
if (node.deadEnd) {
return 'danger';
}
return 'primary';
};
return (
<>
<Button onPress={onOpen}>Find node</Button>
<Modal
isOpen={isOpen}
scrollBehavior="inside"
onOpenChange={onOpenChange}
>
<ModalContent>
{(close) => (
<>
<ModalHeader className="flex flex-col gap-1">Nodes</ModalHeader>
<ModalBody>
<Input
placeholder="Node ID"
value={text}
onValueChange={setText}
/>
<Checkbox isSelected={completed} onValueChange={setCompleted}>
Completed
</Checkbox>
<div className="flex gap-4 flex-wrap">
{nodes.map((node) => (
<Badge
content={node.score}
color={getColor(node)}
key={node.id}
>
<Button
onClick={() => {
selectNode(node);
close();
}}
key={node.id}
>
{node.id}
</Button>
</Badge>
))}
</div>
</ModalBody>
</>
)}
</ModalContent>
</Modal>
</>
);
};
export { FindNodeView };

View File

@@ -0,0 +1,32 @@
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
Button,
useDisclosure,
} from '@nextui-org/react';
import { Graph } from '../../presenters/graph';
const GraphView = () => {
const { isOpen, onOpen, onOpenChange } = useDisclosure();
return (
<>
<Button onPress={onOpen}>Show graph</Button>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="full">
<ModalContent>
{() => (
<>
<ModalHeader className="flex flex-col gap-1">Graph</ModalHeader>
<ModalBody>
<Graph />
</ModalBody>
</>
)}
</ModalContent>
</Modal>
</>
);
};
export { GraphView };

View File

@@ -0,0 +1,67 @@
import { useEffect, useState } from 'react';
import { ExperimentProvider } from '../../features/experiment/context';
import { ExperimentView } from './view';
import { ExperimentInfo } from '../../features/experiment/types';
type PageProps = {
content: () => Promise<{ default: Experiment }>;
};
type Experiment = {
info: ExperimentInfo;
view: React.ComponentType;
};
const ExperimentPage: React.FC<PageProps> = ({ content }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>();
const [experiment, setExperiment] = useState<Experiment>();
useEffect(() => {
setLoading(true);
setError(undefined);
const load = async () => {
try {
const { default: next } = (await content()) as {
default: Experiment;
};
console.log('n', next);
setExperiment(next);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
load();
}, [content]);
if (error) {
return <div>Error: {error.toString()}</div>;
}
if (loading || !experiment) {
return <div>Loading...</div>;
}
return (
<ExperimentProvider experimentInfo={experiment.info}>
<ExperimentView>
<experiment.view />
</ExperimentView>
</ExperimentProvider>
);
};
const pageImports = import.meta.glob('../../experiments/*/index.tsx');
const experiments: {
path: string;
element: JSX.Element;
}[] = Object.entries(pageImports).map(([path, page]) => ({
path: path.replace('../../experiments/', '').replace('/index.tsx', ''),
element: <ExperimentPage content={page as any} />,
}));
export { experiments };

View File

@@ -0,0 +1,116 @@
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
Button,
useDisclosure,
Card,
CardBody,
CardHeader,
} from '@nextui-org/react';
import { useExperimentResult } from '../../../features/experiment';
import { formatTime } from '../../../utils/time';
const InputView = () => {
const data = useExperimentResult();
const { isOpen, onOpen, onOpenChange } = useDisclosure();
if (!data) {
return null;
}
return (
<>
<Button onPress={onOpen}>Show input</Button>
<Modal
isOpen={isOpen}
onOpenChange={onOpenChange}
scrollBehavior="inside"
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">Input</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4">
{data.planables.map((item) => {
const description: [string, string][] = [];
if (item.start) {
if (item.start.min === item.start.max) {
description.push(['start', formatTime(item.start.min)]);
} else {
const min = formatTime(item.start.min);
const max = formatTime(item.start.max);
description.push(['start', `${min} - ${max}`]);
}
}
if (item.count || 0 > 1) {
description.push(['count', item.count?.toString() || '']);
}
if (item.attributes.locations) {
description.push([
'locations',
item.attributes.locations.join(', '),
]);
}
if (item.attributes.capabilities?.requires) {
description.push([
'requires',
item.attributes.capabilities.requires.join(', '),
]);
}
if (item.attributes.capabilities?.perhibits) {
description.push([
'prohibits',
item.attributes.capabilities.perhibits.join(', '),
]);
}
if (item.attributes.capabilities?.provides) {
description.push([
'provides',
item.attributes.capabilities.provides.join(', '),
]);
}
if (item.attributes.capabilities?.consumes) {
description.push([
'consumes',
item.attributes.capabilities.consumes.join(', '),
]);
}
description.push([
'duration',
`${item.duration / 1000 / 60} minutes`,
]);
return (
<Card>
<CardHeader>{item.id}</CardHeader>
<CardBody>
<div>
{description.map((d) => (
<div className="flex justify-between">
<div>
<span className="font-bold">{d[0]}: </span>
</div>
<div>
<span>{d[1]}</span>
</div>
</div>
))}
</div>
</CardBody>
</Card>
);
})}
</div>
</ModalBody>
</ModalContent>
</Modal>
</>
);
};
export { InputView };

View File

@@ -0,0 +1,46 @@
import { GraphNode } from '@bob-the-algorithm/core';
import { Modal, ModalBody, ModalContent, ModalHeader } from '@nextui-org/react';
import { format } from 'date-fns';
import { JSONTree } from 'react-json-tree';
import { useExperimentResult } from '../../../features/experiment';
import { useMemo } from 'react';
type NodeDetailsViewProps = {
node?: GraphNode;
onClose: () => void;
};
const NodeDetailsInfo = ({ node }: { node: GraphNode }) => {
const data = useExperimentResult();
const start = format(new Date(node.time), 'HH:mm');
const planable = useMemo(() => {
if (!data) {
return null;
}
return node.planable
? data.planables.find((n) => n.id === node.planable)
: null;
}, [node, data]);
return (
<div>
<h1>{node.id}</h1>
<p>{start}</p>
<pre></pre>
<JSONTree data={node.context} />
{planable && <JSONTree data={planable} />}
</div>
);
};
const NodeDetailsView = ({ node, onClose }: NodeDetailsViewProps) => {
return (
<Modal size="3xl" isOpen={!!node} onClose={onClose}>
<ModalContent>
<ModalHeader>Item details</ModalHeader>
<ModalBody>{node && <NodeDetailsInfo node={node} />}</ModalBody>
</ModalContent>
</Modal>
);
};
export { NodeDetailsView };

View File

@@ -0,0 +1,44 @@
import { Modal, ModalBody, ModalContent, ModalHeader } from '@nextui-org/react';
import { GraphNode } from '@bob-the-algorithm/core';
import {
useSelectNode,
useSelectedNode,
} from '../../../features/experiment/hooks';
import { Plan } from '../../../presenters/plan';
import { useState } from 'react';
import { NodeDetailsView } from './details';
import { HorizontalPlan } from '../../../presenters/horizontal-plan';
const NodeView = () => {
const node = useSelectedNode();
const selectNode = useSelectNode();
const [selectedItem, setSelectedItem] = useState<GraphNode<any>>();
return (
<>
<Modal
isOpen={!!node}
scrollBehavior="inside"
onClose={() => selectNode(undefined)}
>
<ModalContent>
<ModalHeader>Node</ModalHeader>
<ModalBody>
{node && (
<>
<HorizontalPlan node={node} />
<Plan node={node} onSelect={setSelectedItem} />
</>
)}
</ModalBody>
</ModalContent>
</Modal>
<NodeDetailsView
node={selectedItem}
onClose={() => setSelectedItem(undefined)}
/>
</>
);
};
export { NodeView };

View File

@@ -0,0 +1,17 @@
import { useExperimentResult } from '../../../features/experiment';
import { useExperimentDuration } from '../../../features/experiment/hooks';
const Stats = () => {
const data = useExperimentResult();
const duration = useExperimentDuration();
return (
<div className="flex gap-2">
<div>Nodes: {data?.nodes.length}</div>
<div>Completed: {data?.completed.length}</div>
<div>Duration: {duration}ms</div>
</div>
);
};
export { Stats };

View File

@@ -0,0 +1,35 @@
import { Content } from '../../components/content';
import { Frame } from '../../components/frame';
import { FindNodeView } from './find-node';
import { GraphView } from './graph';
import { InputView } from './input';
import { NodeView } from './node';
import { Stats } from './stats';
type ExperimentViewProps = {
children: React.ReactNode;
};
const ExperimentView: React.FC<ExperimentViewProps> = ({ children }) => {
return (
<Frame>
<div className="flex flex-row h-full">
<div className="flex flex-col flex-1 h-full">
<div className="flex-1">
<Content>{children}</Content>
</div>
<div className="flex-initial p-2 flex gap-2 items-center">
<GraphView />
<InputView />
<FindNodeView />
<div className="flex-1" />
<Stats />
</div>
</div>
<NodeView />
</div>
</Frame>
);
};
export { ExperimentView };

View File

@@ -1,51 +0,0 @@
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 };

View File

@@ -0,0 +1,15 @@
import { Page } from './page';
const pageImports = import.meta.glob('../../pages/**/*.tsx');
const pages: any = Object.entries(pageImports).map(([path, page]) => ({
path: path
.replace('../../pages/', '')
.replace('index.tsx', '')
.replace('.tsx', ''),
element: <Page content={page as any} />,
}));
console.log('pages', pages);
export { pages };

View File

@@ -0,0 +1,38 @@
import { useEffect, useState } from 'react';
import { Frame } from '../../components/frame';
import { Skeleton } from '@nextui-org/react';
import { Content } from '../../components/content';
type Props = {
content: () => Promise<{ Page: (props: any) => JSX.Element }>;
};
const Page = ({ content }: Props) => {
const [Component, setComponent] = useState<React.ComponentType>();
useEffect(() => {
const run = async () => {
const component = await content();
setComponent(() => component.Page);
};
run();
}, [content]);
return (
<Frame>
<Content>
{!!Component ? (
<article className="my-10">
<Component />
</article>
) : (
<div className="w-full flex flex-col gap-2">
<Skeleton className="h-3 w-3/5 rounded-lg" />
<Skeleton className="h-3 w-4/5 rounded-lg" />
</div>
)}
</Content>
</Frame>
);
};
export { Page };

View File

@@ -0,0 +1,163 @@
import { ExperimentInfo } from '../../features/experiment/types';
const MIN = 1000 * 60;
const HOUR = 1000 * 60 * 60;
const info: ExperimentInfo = {
start: 0,
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'],
capabilities: {
consumes: ['kids'],
requires: ['kids'],
},
},
score: 1,
start: {
min: 7 * HOUR,
max: 9 * HOUR,
},
},
{
id: 'visit zoo',
duration: 1 * HOUR,
attributes: {
locations: ['zoo'],
capabilities: {
requires: ['kids'],
},
},
score: 1,
start: {
min: 10 * HOUR,
max: 14 * HOUR,
},
},
{
id: 'Pickup the kids',
duration: 30 * MIN,
attributes: {
locations: ['daycare'],
capabilities: {
provides: ['kids'],
},
},
score: 1,
start: {
min: 10 * HOUR,
max: 15.5 * HOUR,
},
},
{
id: 'Do work',
duration: 1 * HOUR,
count: 5,
attributes: {
locations: ['work'],
capabilities: {
perhibits: ['kids'],
},
},
score: 10,
start: {
min: 8 * HOUR,
max: 18 * HOUR,
},
},
{
id: 'put kids to bed',
duration: 30 * MIN,
attributes: {
locations: ['home'],
capabilities: {
consumes: ['kids'],
requires: ['kids'],
},
},
score: 1,
},
{
id: 'Read book',
duration: 0.5 * HOUR,
attributes: {
locations: ['home', 'work'],
capabilities: {
perhibits: ['kids'],
},
},
score: 3,
count: 2,
start: {
min: 8 * HOUR,
max: 22 * HOUR,
},
},
{
id: 'Meditate',
duration: 10 * MIN,
score: 1,
attributes: {
locations: ['home', 'work'],
capabilities: {
perhibits: ['kids'],
},
},
start: {
min: 8 * HOUR,
max: 22 * HOUR,
},
},
{
id: 'Meeting 1',
duration: 1 * HOUR,
attributes: {
locations: ['work', 'work'],
capabilities: {
perhibits: ['kids'],
},
},
score: 10,
start: {
min: 10 * HOUR,
max: 10 * HOUR,
},
},
{
id: 'Play playstation',
duration: 1 * HOUR,
attributes: {
locations: ['home'],
capabilities: {
perhibits: ['kids'],
},
},
score: 10,
start: {
min: 16 * HOUR,
max: 24 * HOUR,
},
},
],
context: {
location: 'home',
capabilities: ['kids'],
},
};
export { info };

View File

@@ -0,0 +1,6 @@
import { info } from './experiment';
export default {
info,
view: () => <>Hello</>,
};

View File

@@ -0,0 +1,3 @@
# Hello
world

View File

@@ -0,0 +1,204 @@
import { ExperimentInfo } from '../../features/experiment/types';
const MIN = 1000 * 60;
const HOUR = 1000 * 60 * 60;
const info: ExperimentInfo = {
context: {
location: 'home',
capabilities: ['kids'],
},
start: 0,
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'],
capabilities: {
requires: ['kids'],
consumes: ['kids'],
},
},
score: 1,
start: {
min: 7 * HOUR,
max: 9 * HOUR,
},
},
{
id: 'put kids to bed',
duration: 30 * MIN,
attributes: {
locations: ['home'],
capabilities: {
consumes: ['kids'],
requires: ['kids'],
},
},
start: {
min: 18.5 * HOUR,
max: 19.5 * HOUR,
},
score: 1,
},
{
id: 'Pickup the kids',
duration: 30 * MIN,
attributes: {
locations: ['daycare'],
capabilities: {
provides: ['kids'],
},
},
score: 1,
start: {
min: 15 * HOUR,
max: 16 * HOUR,
},
},
{
id: `Eat breakfast`,
duration: 15 * MIN,
start: {
min: 7 * HOUR,
max: 9 * HOUR,
},
attributes: {
locations: ['home'],
},
score: 1,
},
{
id: 'Eat dinner',
duration: 60 * MIN,
attributes: {
locations: ['home'],
capabilities: {
requires: ['kids'],
},
},
score: 1,
start: {
min: 17 * HOUR,
max: 22 * HOUR,
},
},
{
id: 'Have lunch',
duration: 30 * MIN,
attributes: {},
score: 1,
start: {
min: 11 * HOUR,
max: 13.5 * HOUR,
},
},
{
id: 'Do work',
duration: 1 * HOUR,
count: 5,
attributes: {
locations: ['work'],
capabilities: {
perhibits: ['kids'],
},
},
score: 10,
start: {
min: 8 * HOUR,
max: 22 * HOUR,
},
},
{
id: 'Read book',
duration: 0.5 * HOUR,
attributes: {
locations: ['home', 'work'],
capabilities: {
perhibits: ['kids'],
},
},
score: 3,
count: 2,
start: {
min: 8 * HOUR,
max: 22 * HOUR,
},
},
{
id: 'Meditate',
duration: 10 * MIN,
score: 1,
attributes: {
locations: ['home', 'work'],
capabilities: {
perhibits: ['kids'],
},
},
start: {
min: 8 * HOUR,
max: 22 * HOUR,
},
},
{
id: 'Meeting 1',
duration: 1 * HOUR,
attributes: {
locations: ['work'],
capabilities: {
perhibits: ['kids'],
},
},
score: 10,
start: {
min: 10 * HOUR,
max: 10 * HOUR,
},
},
{
id: 'Meeting 2',
duration: 1 * HOUR,
attributes: {
locations: ['work'],
capabilities: {
perhibits: ['kids'],
},
},
score: 10,
start: {
min: 12 * HOUR,
max: 12 * HOUR,
},
},
{
id: 'Play playstation',
duration: 1 * HOUR,
attributes: {
locations: ['home'],
capabilities: {
perhibits: ['kids'],
},
},
score: 10,
start: {
min: 16 * HOUR,
max: 24 * HOUR,
},
},
],
};
export { info };

View File

@@ -0,0 +1,9 @@
import { info } from './experiment';
const Foo = () => {
return <div></div>;
};
export default {
info,
view: Foo,
};

View File

@@ -0,0 +1,111 @@
import {
AllPlugins,
CalulationResult,
GraphNode,
} from '@bob-the-algorithm/core';
import {
ReactNode,
createContext,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { ExperimentInfo } from './types';
type ExperimentResult = {
payload: CalulationResult<AllPlugins>;
duration: number;
};
type ExperimentContextValue = {
result?: ExperimentResult;
error?: any;
loading: boolean;
selectNode: (node?: GraphNode) => void;
selectedNode?: GraphNode;
};
type ExperimentProviderProps = {
children: ReactNode;
experimentInfo: ExperimentInfo;
};
const createWorker = () =>
new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module',
});
const ExperimentContext = createContext<ExperimentContextValue>({
loading: false,
selectNode: () => { },
});
const ExperimentProvider: React.FC<ExperimentProviderProps> = ({
children,
experimentInfo,
}) => {
const [result, setResult] = useState<ExperimentResult>();
const [error, setError] = useState<any>();
const [loading, setLoading] = useState(false);
const [selectedNode, setSelectedNode] = useState<GraphNode>();
const selectNode = useCallback((node?: GraphNode) => {
setSelectedNode(node);
}, []);
useEffect(() => {
let workerInstance: Worker | undefined;
const run = async () => {
setLoading(true);
setError(undefined);
setResult(undefined);
const workerInstance = createWorker();
workerInstance.onmessage = (e) => {
switch (e.data.type) {
case 'error': {
setError(e.data);
break;
}
case 'output': {
setResult(e.data);
break;
}
}
setLoading(false);
};
workerInstance.onerror = (e) => {
setError(e);
setLoading(false);
};
workerInstance.postMessage({
type: 'run',
payload: experimentInfo,
});
};
run();
return () => {
workerInstance?.terminate();
};
}, [experimentInfo]);
const value = useMemo(
() => ({
result,
error,
loading,
selectNode,
selectedNode,
}),
[result, error, loading, selectNode, selectedNode],
);
return (
<ExperimentContext.Provider value={value}>
{children}
</ExperimentContext.Provider>
);
};
export type { ExperimentContextValue };
export { ExperimentContext, ExperimentProvider };

View File

@@ -0,0 +1,29 @@
import { useContext } from 'react';
import { ExperimentContext } from './context';
const useExperimentResult = () => {
const { result } = useContext(ExperimentContext);
return result?.payload;
};
const useExperimentDuration = () => {
const { result } = useContext(ExperimentContext);
return result?.duration;
};
const useSelectNode = () => {
const { selectNode } = useContext(ExperimentContext);
return selectNode;
};
const useSelectedNode = () => {
const { selectedNode } = useContext(ExperimentContext);
return selectedNode;
};
export {
useExperimentResult,
useSelectNode,
useSelectedNode,
useExperimentDuration,
};

View File

@@ -0,0 +1,2 @@
export { ExperimentProvider as Experminent } from './context';
export { useExperimentResult } from './hooks';

View File

@@ -0,0 +1,9 @@
import { AllPlugins, PlanableWithPlugins } from '@bob-the-algorithm/core';
type ExperimentInfo = {
planables: PlanableWithPlugins<AllPlugins>[];
context: any;
start: number;
};
export type { ExperimentInfo };

View File

@@ -0,0 +1,38 @@
import { Bob, plugins } from '@bob-the-algorithm/core';
import { ExperimentInfo } from './types';
const MIN = 1000 * 60;
const getTravelTime = async () => 30 * MIN;
const transport = plugins.transport({
getTravelTime,
});
const run = async (payload: ExperimentInfo) => {
const startTime = performance.now();
try {
const bob = new Bob({
plugins: { transport, capabilities: plugins.capabilities() },
});
const result = await bob.run({
planables: payload.planables,
context: payload.context,
start: payload.start,
heuristic: ({ completed }) => completed.length >= 3,
});
const endTime = performance.now();
const duration = endTime - startTime;
self.postMessage({ type: 'output', payload: result, duration });
} catch (error) {
self.postMessage({ type: 'error', payload: error });
console.error(error);
}
};
self.addEventListener('message', (event) => {
const { type, payload } = event.data;
if (type === 'run') {
run(payload);
}
});

View File

@@ -1,98 +0,0 @@
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 };

View File

@@ -1,42 +0,0 @@
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 };

View File

@@ -1,3 +0,0 @@
export { RunnerProvider } from './context';
export { Block } from './block';
export { createWorker } from './worker';

View File

@@ -1,21 +0,0 @@
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 };

View File

@@ -0,0 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#root,
#root > div {
height: 100%;
}

View File

@@ -0,0 +1,53 @@
import { useNavigate } from 'react-router-dom';
import { Card, CardBody, Button } from '@nextui-org/react';
type Experiment = {
title: string;
slug: string;
description: string;
};
const experiments: Experiment[] = [
{
title: 'Capabilities',
slug: 'capabilities',
description: 'Explore the capabilities of Bob.',
},
{
title: 'Packed day',
slug: 'realistic',
description: 'Explore the capabilities of Bob.',
},
];
const Experiments = () => {
const navigate = useNavigate();
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Experiments</h1>
{experiments.map(({ title, slug, description }) => (
<Card>
<CardBody>
<div className="flex justify-between items-center">
<div className="flex flex-col gap-2">
<p className="text-md">{title}</p>
<p className="text-sm">{description}</p>
</div>
<Button
color="primary"
radius="full"
size="sm"
variant="solid"
onPress={() => navigate(`/experiments/${slug}`)}
>
Show
</Button>
</div>
</CardBody>
</Card>
))}
</div>
);
};
export { Experiments as Page };

View File

@@ -1,7 +0,0 @@
import { Block } from '../../features/runner'
import { worker } from './worker';
import { Presenter } from '../../presenters/graph';
# Hello World
<Block worker={worker} action="realistic" presenter={Presenter} />

View File

@@ -1,148 +0,0 @@
import { calulation, plugins } from '@bob-the-algorithm/core';
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,
});

View File

@@ -1,5 +0,0 @@
const worker = new Worker(new URL('./script.ts', import.meta.url), {
type: 'module',
});
export { worker };

View File

@@ -0,0 +1,5 @@
const Experiments = () => {
return <div>Welcome</div>;
};
export { Experiments as Page };

View File

@@ -1,111 +1,65 @@
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import { GraphCanvas } from 'reagraph';
import { ConvertedResult } from '../../utils/graph';
import { Plan } from './plan';
import { useExperimentResult } from '../../features/experiment';
import { convertResult } from '../../utils/graph';
import {
useSelectNode,
useSelectedNode,
} from '../../features/experiment/hooks';
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 Graph: React.FC = () => {
const data = useExperimentResult();
const selectedNode = useSelectedNode();
const selectNode = useSelectNode();
const output = useMemo(() => {
if (!data) {
return undefined;
}
return convertResult(data);
}, [data]);
const selectedPath = useMemo(() => {
if (!selectedNode) {
return [];
}
const result: string[] = [];
let current = output.result.nodes.find((n) => n.id === selectedNode);
let current = output?.result.nodes.find((n) => n.id === selectedNode.id);
while (current) {
result.push(current.id);
if (!current.parent) {
break;
}
current = output.result.nodes.find((n) => n.id === current?.parent);
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;
}
console.log(output);
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"
layoutType="hierarchicalTd"
onNodeClick={(node) => {
if (node.id === selectedNode) {
setSelectedNode(undefined);
if (node.id === selectedNode?.id) {
selectNode(undefined);
return;
}
setSelectedNode(node.id);
const nextNode = data?.nodes.find((n) => n.id === node.id);
selectNode(nextNode);
}}
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';
}
@@ -131,9 +85,8 @@ const Presenter: React.FC<PresenterProps> = ({ output }) => {
}}
/>
</div>
)}
</>
);
};
export { Presenter };
export { Graph };

View File

@@ -1,76 +0,0 @@
import { GraphNode } from '@bob-the-algorithm/core';
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 };

View File

@@ -0,0 +1,90 @@
import { GraphNode } from '@bob-the-algorithm/core';
import { Popover, PopoverTrigger, PopoverContent } from '@nextui-org/react';
import { useExperimentResult } from '../../features/experiment';
import { useMemo } from 'react';
import { formatTime } from '../../utils/time';
type PlanProps = {
node: GraphNode;
};
const timespan = 24 * 60 * 60 * 1000;
const randomColor = () => Math.floor(Math.random() * 16777215).toString(16);
const HorizontalPlan: React.FC<PlanProps> = ({ node }) => {
const data = useExperimentResult();
const nodes = useMemo(() => {
if (!data) {
return [];
}
const result: GraphNode[] = [];
let current = node;
if (!current) {
return [];
}
while (current) {
result.push(current);
if (!current.parent) {
break;
}
current = data.nodes.find((n) => n.id === current?.parent)!;
}
return result;
}, [data, node]);
if (!data) {
return null;
}
return (
<div className="w-full min-h-unit-6 rounded-lg bg-gray-900 relative">
{nodes.map((node) => {
const time = (
<span>
{formatTime(node.time)} - {formatTime(node.time + node.duration)}
</span>
);
let title = '';
if (node.planable) {
const planable = data!.planables.find((n) => n.id === node.planable);
title = `Planable: ${planable?.id}`;
}
if (node.type === 'travel') {
title = `Travel: ${node.context.location}`;
}
return (
<div
className="absolute top-0 bottom-0 h-full left-0 right-0 p-[2px] flex items-stretch justify-stretch"
style={{
left: `${(node.time / timespan) * 100}%`,
width: `${(node.duration / timespan) * 100}%`,
}}
>
<Popover placement="bottom" showArrow>
<PopoverTrigger>
<div
className="flex-1 rounded-[2px]"
style={{
left: `${(node.time / timespan) * 100}%`,
width: `${(node.duration / timespan) * 100}%`,
backgroundColor: `#${randomColor()}`,
}}
/>
</PopoverTrigger>
<PopoverContent>
<div className="px-1 py-2">
<div className="text-small font-bold">{title}</div>
<div className="text-tiny">{time}</div>
</div>
</PopoverContent>
</Popover>
</div>
);
})}
</div>
);
};
export { HorizontalPlan };

View File

@@ -0,0 +1,74 @@
import { GraphNode } from '@bob-the-algorithm/core';
import { useMemo } from 'react';
import { Listbox, ListboxItem, cn } from '@nextui-org/react';
import { useExperimentResult } from '../../features/experiment';
import { formatTime } from '../../utils/time';
type PlanProps = {
node: GraphNode;
onSelect: (node: GraphNode) => void;
};
const Plan: React.FC<PlanProps> = ({ node, onSelect }) => {
const data = useExperimentResult();
const nodes = useMemo(() => {
if (!data) {
return [];
}
const result: GraphNode[] = [];
let current = node;
if (!current) {
return [];
}
while (current) {
result.push(current);
if (!current.parent) {
break;
}
current = data.nodes.find((n) => n.id === current?.parent)!;
}
return result.reverse();
}, [data, node]);
if (!data) {
return null;
}
return (
<Listbox
variant="flat"
aria-label="Listbox menu with descriptions"
items={nodes}
>
{(node) => {
const time = (
<span>
{formatTime(node.time)} - {formatTime(node.time + node.duration)}
</span>
);
let title = '';
if (node.planable) {
const planable = data!.planables.find((n) => n.id === node.planable);
title = `Planable: ${planable?.id}`;
}
if (node.type === 'travel') {
title = `Travel: ${node.context.location}`;
}
return (
<ListboxItem
key={node.id}
className={cn({ selected: node.id === node.id })}
onClick={() => onSelect(node)}
description={time}
>
{title}
</ListboxItem>
);
}}
</Listbox>
);
};
export { Plan };

View File

@@ -0,0 +1,18 @@
import { createHashRouter, RouterProvider } from 'react-router-dom';
import { pages } from '../containers/page';
import { experiments } from '../containers/experiment';
const router = createHashRouter([
{
path: '/',
children: pages,
},
{
path: '/experiments',
children: experiments,
},
]);
const Router = () => <RouterProvider router={router} />;
export { Router };

View File

@@ -1,4 +1,4 @@
import { CalulationResult } from '@bob-the-algorithm/core';
import { AllPlugins, CalulationResult } from '@bob-the-algorithm/core';
function msToHMS(ms: number) {
// 1- Convert to seconds:
@@ -12,13 +12,13 @@ function msToHMS(ms: number) {
seconds = seconds % 60;
return hours + ':' + minutes + ':' + seconds;
}
const convertResult = (result: CalulationResult<any>) => {
const convertResult = (result: CalulationResult<AllPlugins>) => {
const nodes = result.nodes.map((node) => {
let label = `root (${node.location})`;
let label = `root (${node.context.location})`;
if (node.type === 'planable') {
label = `task: ${node.planable!.toString()}`;
} else if (node.type === 'travel') {
label = `travel->${node.location}`;
label = `travel->${node.context.location}`;
}
return {
id: node.id,

View File

@@ -1,8 +0,0 @@
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 };

View File

@@ -0,0 +1,6 @@
const formatTime = (time: number) => {
const toUtc = new Date(time).toUTCString();
return toUtc.slice(17, 22);
};
export { formatTime };

View File

@@ -0,0 +1,19 @@
/** @type {import('tailwindcss').Config} */
const path = require('path');
const { nextui } = require('@nextui-org/react');
const themePath = require.resolve('@nextui-org/theme/package.json');
const themeDir = path.dirname(themePath);
export default {
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
`${themeDir}/dist/**/*.{js,ts,jsx,tsx}`,
],
theme: {
extend: {},
},
darkMode: 'class',
plugins: [nextui()],
};

View File

@@ -6,6 +6,6 @@ const ASSET_URL = process.env.ASSET_URL || '';
// https://vitejs.dev/config/
export default defineConfig({
base: `${ASSET_URL}/dist/`,
base: ASSET_URL,
plugins: [mdx(), react()],
});

2675
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff