Merge branch 'dev' into refactor/node-encapsulation

This commit is contained in:
Björn Otgaar
2025-11-18 12:35:53 +01:00
36 changed files with 2535 additions and 55 deletions

View File

@@ -82,6 +82,10 @@ button.movePage:hover{
}
#root {
display: flex;
flex-direction: column;
}
header {
position: sticky;
@@ -96,6 +100,7 @@ header {
align-items: center;
justify-content: center;
background-color: var(--accent-color);
backdrop-filter: blur(10px);
z-index: 1; /* Otherwise any translated elements render above the blur?? */
}
@@ -104,6 +109,10 @@ main {
padding: 1rem 0;
}
input[type="checkbox"] {
cursor: pointer;
}
.flex-row {
display: flex;
flex-direction: row;
@@ -121,6 +130,14 @@ main {
flex-wrap: wrap;
}
.min-height-0 {
min-height: 0;
}
.scroll-y {
overflow-y: scroll;
}
.align-center {
align-items: center;
}
@@ -141,6 +158,10 @@ main {
gap: 1rem;
}
.margin-0 {
margin: 0;
}
.padding-sm {
padding: .25rem;
}
@@ -150,7 +171,19 @@ main {
.padding-lg {
padding: 1rem;
}
.padding-b-sm {
padding-bottom: .25rem;
}
.padding-b-md {
padding-bottom: .5rem;
}
.padding-b-lg {
padding-bottom: 1rem;
}
.round-sm, .round-md, .round-lg {
overflow: hidden;
}
.round-sm {
border-radius: .25rem;
}
@@ -159,4 +192,59 @@ main {
}
.round-lg {
border-radius: 1rem;
}
}
.border-sm {
border: 1px solid canvastext;
}
.border-md {
border: 2px solid canvastext;
}
.border-lg {
border: 3px solid canvastext;
}
.font-small {
font-size: .75rem;
}
.font-medium {
font-size: 1rem;
}
.font-large {
font-size: 1.25rem;
}
.mono {
font-family: ui-monospace, monospace;
}
.bold {
font-weight: bold;
}
.clickable {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.user-select-all {
-webkit-user-select: all;
user-select: all;
}
.user-select-none {
-webkit-user-select: none;
user-select: none;
}
button.no-button {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}

View File

@@ -3,24 +3,34 @@ import './App.css'
import TemplatePage from './pages/TemplatePage/Template.tsx'
import Home from './pages/Home/Home.tsx'
import Robot from './pages/Robot/Robot.tsx';
import ConnectedRobots from './pages/ConnectedRobots/ConnectedRobots.tsx'
import VisProg from "./pages/VisProgPage/VisProg.tsx";
import {useState} from "react";
import Logging from "./components/Logging/Logging.tsx";
function App(){
const [showLogs, setShowLogs] = useState(false);
return (
<div>
<>
<header>
<Link to={"/"}>Home</Link>
<button onClick={() => setShowLogs(!showLogs)}>Toggle Logging</button>
</header>
<main className={"flex-col align-center"}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/template" element={<TemplatePage />} />
<Route path="/editor" element={<VisProg />} />
<Route path="/robot" element={<Robot />} />
<div className={"flex-row justify-center flex-1 min-height-0"}>
<main className={"flex-col align-center flex-1 scroll-y"}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/template" element={<TemplatePage />} />
<Route path="/editor" element={<VisProg />} />
<Route path="/robot" element={<Robot />} />
<Route path="/ConnectedRobots" element={<ConnectedRobots />} />
</Routes>
</main>
</div>
)
</main>
{showLogs && <Logging />}
</div>
</>
);
}
export default App

View File

@@ -0,0 +1,34 @@
.filter-root {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.filter-panel {
position: absolute;
display: flex;
flex-direction: column;
gap: .25rem;
top: 0;
right: 0;
z-index: 1;
background: canvas;
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
width: 300px;
*:first-child {
margin-top: 0;
}
*:last-child {
margin-bottom: 0;
}
}
button.deletable {
cursor: pointer;
&:hover {
text-decoration: line-through;
}
}

View File

@@ -0,0 +1,200 @@
import {useEffect, useRef, useState} from "react";
import type {LogFilterPredicate} from "./useLogs.ts";
import styles from "./Filters.module.css";
type Setter<T> = (value: T | ((prev: T) => T)) => void;
const optionMapping = new Map([
["ALL", 0],
["DEBUG", 10],
["INFO", 20],
["WARNING", 30],
["ERROR", 40],
["CRITICAL", 50],
["NONE", 999_999_999_999], // It is technically possible to have a higher level, but this is fine
]);
function LevelPredicateElement({
name,
level,
setLevel,
onDelete,
}: {
name: string;
level: string;
setLevel: (level: string) => void;
onDelete?: () => void;
}) {
const normalizedName = name.split(".").pop() || name;
return <div className={"flex-row gap-sm align-center"}>
<label
htmlFor={`log_level_${name}`}
className={"font-small"}
>
{onDelete
? <button
className={`no-button ${styles.deletable}`}
onClick={onDelete}
>{normalizedName}:</button>
: normalizedName + ':'
}
</label>
<select
id={`log_level_${name}`}
value={level}
onChange={(e) => setLevel(e.target.value)}
>
{Array.from(optionMapping.keys()).map((key) => (
<option key={key} value={key}>{key}</option>
))}
</select>
</div>
}
const GLOBAL_LOG_LEVEL_PREDICATE_KEY = "global_log_level";
function GlobalLevelFilter({
filterPredicates,
setFilterPredicates,
}: {
filterPredicates: Map<string, LogFilterPredicate>;
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
}) {
const selected = filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value || "ALL";
const setSelected = (selected: string | null) => {
if (!selected || !optionMapping.has(selected)) return;
setFilterPredicates((curr) => {
const next = new Map(curr);
next.set(GLOBAL_LOG_LEVEL_PREDICATE_KEY, {
predicate: (record) => record.levelno >= optionMapping.get(selected)!,
priority: 0,
value: selected,
});
return next;
});
}
useEffect(() => {
if (filterPredicates.has(GLOBAL_LOG_LEVEL_PREDICATE_KEY)) return;
setSelected("INFO");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run only once when the component mounts, not when anything changes
return <LevelPredicateElement
name={"Global"}
level={selected}
setLevel={setSelected}
/>;
}
const AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX = "agent_log_level_";
function AgentLevelFilters({
filterPredicates,
setFilterPredicates,
agentNames,
}: {
filterPredicates: Map<string, LogFilterPredicate>;
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
agentNames: Set<string>;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
// Click outside to close
useEffect(() => {
if (!open) return;
const onDocClick = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
setOpen(false);
e.preventDefault(); // Don't exit fullscreen mode
};
document.addEventListener("mousedown", onDocClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const agentPredicates = [...filterPredicates.keys()].filter((key) =>
key.startsWith(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX));
/**
* Create or change the predicate for an agent. If the level is not given, the global level is used.
* @param agentName The name of the agent.
* @param level The level to filter by. If not given, the global level is used.
*/
const setAgentPredicate = (agentName: string, level?: string ) => {
level = level ?? filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value ?? "ALL";
setFilterPredicates((prev) => {
const next = new Map(prev);
next.set(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName, {
predicate: (record) => record.name === agentName
? record.levelno >= optionMapping.get(level!)!
: null,
priority: 1,
value: {agentName, level},
});
return next;
});
}
const deleteAgentPredicate = (agentName: string) => {
setFilterPredicates((curr) => {
const fullName = AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName;
if (!curr.has(fullName)) return curr; // Return unchanged, no re-render
const next = new Map(curr);
next.delete(fullName);
return next;
});
}
return <>
{agentPredicates.map((key) => {
const {agentName, level} = filterPredicates.get(key)!.value;
return <LevelPredicateElement
key={key}
name={agentName}
level={level}
setLevel={(level) => setAgentPredicate(agentName, level)}
onDelete={() => deleteAgentPredicate(agentName)}
/>;
})}
<div className={"flex-row gap-sm align-center"}>
<label htmlFor={"add_agent"} className={"font-small"}>Add:</label>
<select
id={"add_agent"}
value={""}
onChange={(e) => !!e.target.value && setAgentPredicate(e.target.value)}
>
{["", ...agentNames.keys()].map((key) => (
<option key={key} value={key}>{key.split(".").pop()}</option>
))}
</select>
</div>
</>;
}
export default function Filters({
filterPredicates,
setFilterPredicates,
agentNames,
}: {
filterPredicates: Map<string, LogFilterPredicate>;
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
agentNames: Set<string>;
}) {
return <div className={"flex-1 flex-row flex-wrap gap-md align-center"}>
<GlobalLevelFilter filterPredicates={filterPredicates} setFilterPredicates={setFilterPredicates} />
<AgentLevelFilters filterPredicates={filterPredicates} setFilterPredicates={setFilterPredicates} agentNames={agentNames} />
</div>;
}

View File

@@ -0,0 +1,39 @@
.logging-container {
box-sizing: border-box;
width: max(30dvw, 500px);
flex-shrink: 0;
box-shadow: 0 0 1rem black;
padding: 1rem 1rem 0 1rem;
}
.no-numbers {
list-style-type: none;
counter-reset: none;
padding-inline-start: 0;
}
.log-container {
margin-bottom: .5rem;
.accented-0, .accented-10 {
background-color: color-mix(in oklab, canvas, rgb(159, 159, 159) 35%)
}
.accented-20 {
background-color: color-mix(in oklab, canvas, green 35%)
}
.accented-30 {
background-color: color-mix(in oklab, canvas, yellow 35%)
}
.accented-40, .accented-50 {
background-color: color-mix(in oklab, canvas, red 35%)
}
}
.floating-button {
position: fixed;
bottom: 1rem;
right: 1rem;
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
}

View File

@@ -0,0 +1,129 @@
import {useEffect, useRef, useState} from "react";
import {create} from "zustand";
import formatDuration from "../../utils/formatDuration.ts";
import {type LogFilterPredicate, type LogRecord, useLogs} from "./useLogs.ts";
import Filters from "./Filters.tsx";
import {type Cell, useCell} from "../../utils/cellStore.ts";
import styles from "./Logging.module.css";
type LoggingSettings = {
showRelativeTime: boolean;
setShowRelativeTime: (showRelativeTime: boolean) => void;
scrollToBottom: boolean;
setScrollToBottom: (scrollToBottom: boolean) => void;
};
const useLoggingSettings = create<LoggingSettings>((set) => ({
showRelativeTime: false,
setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }),
scrollToBottom: true,
setScrollToBottom: (scrollToBottom: boolean) => set({ scrollToBottom }),
}));
function LogMessage({
recordCell,
onUpdate,
}: {
recordCell: Cell<LogRecord>,
onUpdate?: () => void,
}) {
const { showRelativeTime, setShowRelativeTime } = useLoggingSettings();
const record = useCell(recordCell);
/**
* Normalizes the log level number to a multiple of 10, for which there are CSS styles.
*/
const normalizedLevelNo = (() => {
// By default, the highest level is 50 (CRITICAL). Custom levels can be higher, but we don't have more critical color.
if (record.levelno >= 50) return 50;
return Math.round(record.levelno / 10) * 10;
})();
const normalizedName = record.name.split(".").pop() || record.name;
useEffect(() => {
if (onUpdate) onUpdate();
}, [record, onUpdate]);
return <div className={`${styles.logContainer} round-md border-lg flex-row gap-md`}>
<div className={`${styles[`accented${normalizedLevelNo}`]} flex-col padding-sm justify-between`}>
<span className={"mono bold"}>{record.levelname}</span>
<span className={"mono clickable font-small"}
onClick={() => setShowRelativeTime(!showRelativeTime)}
>{showRelativeTime
? formatDuration(record.relativeCreated)
: new Date(record.created * 1000).toLocaleTimeString()
}</span>
</div>
<div className={"flex-col flex-1 padding-sm"}>
<span className={"mono"}>{normalizedName}</span>
<span>{record.message}</span>
</div>
</div>;
}
function LogMessages({ recordCells }: { recordCells: Cell<LogRecord>[] }) {
const scrollableRef = useRef<HTMLDivElement>(null);
const lastElementRef = useRef<HTMLLIElement>(null)
const { scrollToBottom, setScrollToBottom } = useLoggingSettings();
useEffect(() => {
if (!scrollableRef.current) return;
const currentScrollableRef = scrollableRef.current;
const handleScroll = () => setScrollToBottom(false);
currentScrollableRef.addEventListener("wheel", handleScroll);
currentScrollableRef.addEventListener("touchmove", handleScroll);
return () => {
currentScrollableRef.removeEventListener("wheel", handleScroll);
currentScrollableRef.removeEventListener("touchmove", handleScroll);
}
}, [scrollableRef, setScrollToBottom]);
function scrollLastElementIntoView(force = false) {
if ((!scrollToBottom && !force) || !lastElementRef.current) return;
lastElementRef.current.scrollIntoView({ behavior: "smooth" });
}
return <div ref={scrollableRef} className={"min-height-0 scroll-y padding-b-md"}>
<ol className={`${styles.noNumbers} margin-0 flex-col gap-md`}>
{recordCells.map((recordCell, i) => (
<li key={`${i}_${recordCell.get().firstRelativeCreated}`}>
<LogMessage recordCell={recordCell} onUpdate={scrollLastElementIntoView} />
</li>
))}
<li ref={lastElementRef}></li>
</ol>
{!scrollToBottom && <button
className={styles.floatingButton}
onClick={() => {
setScrollToBottom(true);
scrollLastElementIntoView(true);
}}
>
Scroll to bottom
</button>}
</div>;
}
export default function Logging() {
const [filterPredicates, setFilterPredicates] = useState(new Map<string, LogFilterPredicate>());
const { filteredLogs, distinctNames } = useLogs(filterPredicates)
return <div className={`flex-col gap-lg min-height-0 ${styles.loggingContainer}`}>
<div className={"flex-row gap-lg justify-between align-center"}>
<h2 className={"margin-0"}>Logs</h2>
<Filters
filterPredicates={filterPredicates}
setFilterPredicates={setFilterPredicates}
agentNames={distinctNames}
/>
</div>
<LogMessages recordCells={filteredLogs} />
</div>;
}

View File

@@ -0,0 +1,146 @@
import {useCallback, useEffect, useRef, useState} from "react";
import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../utils/priorityFiltering.ts";
import {cell, type Cell} from "../../utils/cellStore.ts";
export type LogRecord = {
name: string;
message: string;
levelname: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string;
levelno: number;
created: number;
relativeCreated: number;
reference?: string;
firstCreated: number;
firstRelativeCreated: number;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type LogFilterPredicate = PriorityFilterPredicate<LogRecord> & { value: any };
export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
const [distinctNames, setDistinctNames] = useState<Set<string>>(new Set());
const [filtered, setFiltered] = useState<Cell<LogRecord>[]>([]);
const sseRef = useRef<EventSource | null>(null);
const filtersRef = useRef(filterPredicates);
const logsRef = useRef<LogRecord[]>([]);
/** Map to store the first message for each reference, instance can be updated to change contents. */
const firstByRefRef = useRef<Map<string, Cell<LogRecord>>>(new Map());
/**
* Apply the filter predicates to a log record.
* @param log The log record to apply the filters to.
* @returns `true` if the record passes.
*/
const applyFilters = useCallback((log: LogRecord) =>
applyPriorityPredicates(log, [...filtersRef.current.values()]), []);
/** Recomputes the entire filtered list. Use when filter predicates change. */
const recomputeFiltered = useCallback(() => {
const newFiltered: Cell<LogRecord>[] = [];
firstByRefRef.current = new Map();
for (const message of logsRef.current) {
const messageCell = cell<LogRecord>({
...message,
firstCreated: message.created,
firstRelativeCreated: message.relativeCreated,
});
if (message.reference) {
const first = firstByRefRef.current.get(message.reference);
if (first) {
// Update the first's contents
first.set((prev) => ({
...message,
firstCreated: prev.firstCreated ?? prev.created,
firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated,
}));
// Don't add it to the list again
continue;
} else {
// Add the first message with this reference to the registry
firstByRefRef.current.set(message.reference, messageCell);
}
}
if (applyFilters(message)) {
newFiltered.push(messageCell);
}
}
setFiltered(newFiltered);
}, [applyFilters, setFiltered]);
// Reapply filters to all logs, only when filters change
useEffect(() => {
filtersRef.current = filterPredicates;
recomputeFiltered();
}, [filterPredicates, recomputeFiltered]);
/**
* Handle a new log message. Updates the filtered list and to the full history.
* @param message The new log message.
*/
const handleNewMessage = useCallback((message: LogRecord) => {
// Add to the full history for re-filtering on filter changes
logsRef.current.push(message);
setDistinctNames((prev) => {
if (prev.has(message.name)) return prev;
const newSet = new Set(prev);
newSet.add(message.name);
return newSet;
});
const messageCell = cell<LogRecord>({
...message,
firstCreated: message.created,
firstRelativeCreated: message.relativeCreated,
});
if (message.reference) {
const first = firstByRefRef.current.get(message.reference);
if (first) {
// Update the first's contents
first.set((prev) => ({
...message,
firstCreated: prev.firstCreated ?? prev.created,
firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated,
}));
// Don't add it to the list again
return;
} else {
// Add the first message with this reference to the registry
firstByRefRef.current.set(message.reference, messageCell);
}
}
if (applyFilters(message)) {
setFiltered((curr) => [...curr, messageCell]);
}
}, [applyFilters, setFiltered]);
useEffect(() => {
if (sseRef.current) return;
const es = new EventSource("http://localhost:8000/logs/stream");
sseRef.current = es;
es.onmessage = (event) => {
const data: LogRecord = JSON.parse(event.data);
handleNewMessage(data);
};
return () => {
es.close();
sseRef.current = null;
};
}, [handleNewMessage]);
return {filteredLogs: filtered, distinctNames};
}

View File

@@ -0,0 +1,14 @@
import {useEffect, useRef} from "react";
/**
* An element that always scrolls into view when it is rendered. When added to a list, the entire list will scroll to show this element.
*/
export default function ScrollIntoView() {
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (elementRef.current) elementRef.current.scrollIntoView({ behavior: "smooth" });
});
return <div ref={elementRef} />;
}

View File

@@ -0,0 +1,27 @@
.text-field {
border: 1px solid transparent;
border-radius: 5pt;
padding: 4px 8px;
outline: none;
background-color: canvas;
transition: border-color 0.2s, box-shadow 0.2s;
cursor: text;
}
.text-field.invalid {
border-color: red;
color: red;
}
.text-field:focus:not(.invalid) {
border-color: color-mix(in srgb, canvas, #777 10%);
}
.text-field:read-only {
cursor: pointer;
background-color: color-mix(in srgb, canvas, #777 5%);
}
.text-field:read-only:hover:not(.invalid) {
border-color: color-mix(in srgb, canvas, #777 10%);
}

View File

@@ -0,0 +1,101 @@
import {useState} from "react";
import styles from "./TextField.module.css";
/**
* A text input element in our own style that calls `setValue` at every keystroke.
*
* @param {Object} props - The component props.
* @param {string} props.value - The value of the text input.
* @param {(value: string) => void} props.setValue - A function that sets the value of the text input.
* @param {string} [props.placeholder] - The placeholder text for the text input.
* @param {string} [props.className] - Additional CSS classes for the text input.
* @param {string} [props.id] - The ID of the text input.
* @param {string} [props.ariaLabel] - The ARIA label for the text input.
*/
export function RealtimeTextField({
value = "",
setValue,
onCommit,
placeholder,
className,
id,
ariaLabel,
invalid = false,
} : {
value: string,
setValue: (value: string) => void,
onCommit: () => void,
placeholder?: string,
className?: string,
id?: string,
ariaLabel?: string,
invalid?: boolean,
}) {
const [readOnly, setReadOnly] = useState(true);
const updateData = () => {
setReadOnly(true);
onCommit();
};
const updateOnEnter = (event: React.KeyboardEvent<HTMLInputElement>) => { if (event.key === "Enter") (event.target as HTMLInputElement).blur(); };
return <input
type={"text"}
placeholder={placeholder}
value={value}
onChange={(e) => setValue(e.target.value)}
onFocus={() => setReadOnly(false)}
onBlur={updateData}
onKeyDown={updateOnEnter}
readOnly={readOnly}
id={id}
// ReactFlow uses the "drag" / "nodrag" classes to enable / disable dragging of nodes
className={`${readOnly ? "drag" : "nodrag"} ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`}
aria-label={ariaLabel}
/>;
}
/**
* A text input element in our own style that calls `setValue` once the user presses the enter key or clicks outside the input.
*
* @param {Object} props - The component props.
* @param {string} props.value - The value of the text input.
* @param {(value: string) => void} props.setValue - A function that sets the value of the text input.
* @param {string} [props.placeholder] - The placeholder text for the text input.
* @param {string} [props.className] - Additional CSS classes for the text input.
* @param {string} [props.id] - The ID of the text input.
* @param {string} [props.ariaLabel] - The ARIA label for the text input.
*/
export function TextField({
value = "",
setValue,
placeholder,
className,
id,
ariaLabel,
invalid = false,
} : {
value: string,
setValue: (value: string) => void,
placeholder?: string,
className?: string,
id?: string,
ariaLabel?: string,
invalid?: boolean,
}) {
const [inputValue, setInputValue] = useState(value);
const onCommit = () => setValue(inputValue);
return <RealtimeTextField
placeholder={placeholder}
value={inputValue}
setValue={setInputValue}
onCommit={onCommit}
id={id}
className={className}
ariaLabel={ariaLabel}
invalid={invalid}
/>;
}

View File

@@ -7,13 +7,15 @@
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
--accent-color: #008080;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html, body {
html, body, #root {
margin: 0;
padding: 0;
@@ -25,11 +27,7 @@ html, body {
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
color: canvastext;
}
h1 {
@@ -49,7 +47,7 @@ button {
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
border-color: var(--accent-color);
}
button:focus,
button:focus-visible {
@@ -60,9 +58,8 @@ button:focus-visible {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
--accent-color: #00AAAA;
}
button {
background-color: #f9f9f9;

View File

@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react'
export default function ConnectedRobots() {
const [connected, setConnected] = useState<boolean | null>(null);
useEffect(() => {
// We're excepting a stream of data like that looks like this: `data = False` or `data = True`
const eventSource = new EventSource("http://localhost:8000/robot/ping_stream");
eventSource.onmessage = (event) => {
// Receive message and parse
console.log("received message:", event.data);
try {
const data = JSON.parse(event.data);
// Set connected to value.
try {
setConnected(data)
}
catch {
console.log("couldnt extract connected from incoming ping data")
}
} catch {
console.log("Ping message not in correct format:", event.data);
}
};
return () => eventSource.close();
}, []);
return (
<div>
<h1>Is robot currently connected?</h1>
<div>
<h2>Robot is currently: {connected == null ? "checking..." : (connected ? "connected! 🟢" : "not connected... 🔴")} </h2>
<h3>
{connected == null ? "If checking continues, make sure CB is properly loaded with robot at least once." : ""}
</h3>
</div>
</div>
);
}

View File

@@ -14,6 +14,7 @@ function Home() {
<Link to={"/robot"}>Robot Interaction </Link>
<Link to={"/editor"}>Editor </Link>
<Link to={"/template"}>Template </Link>
<Link to={"/ConnectedRobots"}>Connected Robots </Link>
</div>
</div>
)

View File

@@ -1,19 +1,9 @@
/* editor UI */
.outer-editor-container {
margin-inline: auto;
display: flex;
justify-self: center;
padding: 10px;
align-items: center;
width: 80vw;
height: 80vh;
}
.inner-editor-container {
outline-style: solid;
border-radius: 10pt;
width: 90%;
box-sizing: border-box;
margin: 1rem;
width: calc(100% - 2rem);
height: 100%;
}
@@ -81,6 +71,16 @@
filter: drop-shadow(0 0 0.25rem forestgreen);
}
.node-goal {
outline: yellow solid 2pt;
filter: drop-shadow(0 0 0.25rem yellow);
}
.node-trigger {
outline: teal solid 2pt;
filter: drop-shadow(0 0 0.25rem teal);
}
.node-phase {
outline: dodgerblue solid 2pt;
filter: drop-shadow(0 0 0.25rem dodgerblue);
@@ -112,6 +112,22 @@
filter: drop-shadow(0 0 0.25rem forestgreen);
}
.draggable-node-goal {
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
outline: yellow solid 2pt;
filter: drop-shadow(0 0 0.25rem yellow);
}
.draggable-node-trigger {
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
outline: teal solid 2pt;
filter: drop-shadow(0 0 0.25rem teal);
}
.draggable-node-phase {
padding: 3px 10px;
background-color: canvas;

View File

@@ -10,7 +10,6 @@ type ToolbarProps = {
allowDelete: boolean;
};
/**
* Node Toolbar definition:
* handles: node deleting functionality

View File

@@ -0,0 +1,121 @@
import {Handle, type NodeProps, Position} from "@xyflow/react";
import useFlowStore from "../VisProgStores.tsx";
import styles from "../../VisProg.module.css";
import {RealtimeTextField, TextField} from "../../../../components/TextField.tsx";
import {Toolbar} from "./NodeComponents.tsx";
import {useState} from "react";
import duplicateIndices from "../../../../utils/duplicateIndices.ts";
import type { TriggerNode } from "../nodes/TriggerNode.tsx";
export type EmotionTriggerNodeProps = {
type: "emotion";
value: string;
}
type Keyword = { id: string, keyword: string };
export type KeywordTriggerNodeProps = {
type: "keywords";
value: Keyword[];
}
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;
function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) {
const [input, setInput] = useState("");
const text_input_id = "keyword_adder_input";
return <div className={"flex-row gap-md"}>
<label htmlFor={text_input_id}>New Keyword:</label>
<RealtimeTextField
id={text_input_id}
value={input}
setValue={setInput}
onCommit={() => {
if (!input) return;
addKeyword(input);
setInput("");
}}
placeholder={"..."}
className={"flex-1"}
/>
</div>;
}
function Keywords({
keywords,
setKeywords,
}: {
keywords: Keyword[];
setKeywords: (keywords: Keyword[]) => void;
}) {
type Interpolatable = string | number | boolean | bigint | null | undefined;
const inputElementId = (id: Interpolatable) => `keyword_${id}_input`;
/** Indices of duplicates in the keyword array. */
const [duplicates, setDuplicates] = useState<number[]>([]);
function replace(id: string, value: string) {
value = value.trim();
const newKeywords = value === ""
? keywords.filter((kw) => kw.id != id)
: keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw);
setKeywords(newKeywords);
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
}
function add(value: string) {
value = value.trim();
if (value === "") return;
const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}];
setKeywords(newKeywords);
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
}
return <>
<span>Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken.</span>
{[...keywords].map(({id, keyword}, index) => {
return <div key={id} className={"flex-row gap-md"}>
<label htmlFor={inputElementId(id)}>Keyword:</label>
<TextField
id={inputElementId(id)}
value={keyword}
setValue={(val) => replace(id, val)}
placeholder={"..."}
className={"flex-1"}
invalid={duplicates.includes(index)}
/>
</div>;
})}
<KeywordAdder addKeyword={add} />
</>;
}
// export default function TriggerNodeComponent({
// id,
// data,
// }: NodeProps<TriggerNode>) {
// const {updateNodeData} = useFlowStore();
// const setKeywords = (keywords: Keyword[]) => {
// updateNodeData(id, {...data, value: keywords});
// }
// return <>
// <Toolbar nodeId={id} allowDelete={true}/>
// <div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
// {data.type === "emotion" && (
// <div className={"flex-row gap-md"}>Emotion?</div>
// )}
// {data.type === "keywords" && (
// <Keywords
// keywords={data.value}
// setKeywords={setKeywords}
// />
// )}
// <Handle type="source" position={Position.Right} id="TriggerSource"/>
// </div>
// </>;
// }

View File

@@ -0,0 +1,11 @@
import type { GoalNodeData } from "./GoalNode";
/**
* Default data for this node
*/
export const GoalNodeDefaults: GoalNodeData = {
label: "Goal Node",
droppable: true,
GoalList: [],
hasReduce: true,
};

View File

@@ -0,0 +1,78 @@
import {
Handle,
type NodeProps,
Position,
type Connection,
type Edge,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
/**
* The default data dot a Goal node
* @param label: the label of this Goal
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
* @param children: ID's of children of this node
*/
export type GoalNodeData = {
label: string;
droppable: boolean;
GoalList: string[];
hasReduce: boolean;
};
export type GoalNode = Node<GoalNodeData>
export function GoalNodeCanConnect(connection: Connection | Edge): boolean {
return (connection != undefined);
}
/**
* Defines how a Goal node should be rendered
* @param props NodeProps, like id, label, children
* @returns React.JSX.Element
*/
export default function GoalNode(props: NodeProps<Node>) {
const label_input_id = `Goal_${props.id}_label_input`;
const data = props.data as GoalNodeData;
return (
<>
<Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeGoal}`}>
<div className={"flex-row gap-sm"}>
<label htmlFor={label_input_id}></label>
{props.data.label as string}
</div>
{data.GoalList.map((Goal) => (<div>{Goal}</div>))}
<Handle type="target" position={Position.Right} id="phase"/>
</div>
</>
);
}
/**
* Reduces each Goal, including its children down into its relevant data.
* @param props: The Node Properties of this node.
*/
export function GoalReduce(node: Node, nodes: Node[]) {
// Replace this for nodes functionality
if (nodes.length <= -1) {
console.warn("Impossible nodes length in GoalReduce")
}
const data = node.data as GoalNodeData;
return {
label: data.label,
list: data.GoalList,
}
}
export function GoalConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
// Replace this for connection logic
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
console.warn("Impossible node connection called in EndConnects")
}
}

View File

@@ -0,0 +1,11 @@
import type { TriggerNodeData } from "./TriggerNode";
/**
* Default data for this node
*/
export const TriggerNodeDefaults: TriggerNodeData = {
label: "Trigger Node",
droppable: true,
TriggerList: [],
hasReduce: true,
};

View File

@@ -0,0 +1,78 @@
import {
Handle,
type NodeProps,
Position,
type Connection,
type Edge,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
/**
* The default data dot a Trigger node
* @param label: the label of this Trigger
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
* @param children: ID's of children of this node
*/
export type TriggerNodeData = {
label: string;
droppable: boolean;
TriggerList: string[];
hasReduce: boolean;
};
export type TriggerNode = Node<TriggerNodeData>
export function TriggerNodeCanConnect(connection: Connection | Edge): boolean {
return (connection != undefined);
}
/**
* Defines how a Trigger node should be rendered
* @param props NodeProps, like id, label, children
* @returns React.JSX.Element
*/
export default function TriggerNode(props: NodeProps<Node>) {
const label_input_id = `Trigger_${props.id}_label_input`;
const data = props.data as TriggerNodeData;
return (
<>
<Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeTrigger}`}>
<div className={"flex-row gap-sm"}>
<label htmlFor={label_input_id}></label>
{props.data.label as string}
</div>
{data.TriggerList.map((Trigger) => (<div>{Trigger}</div>))}
<Handle type="target" position={Position.Right} id="phase"/>
</div>
</>
);
}
/**
* Reduces each Trigger, including its children down into its relevant data.
* @param props: The Node Properties of this node.
*/
export function TriggerReduce(node: Node, nodes: Node[]) {
// Replace this for nodes functionality
if (nodes.length <= -1) {
console.warn("Impossible nodes length in TriggerReduce")
}
const data = node.data as TriggerNodeData;
return {
label: data.label,
list: data.TriggerList,
}
}
export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) {
// Replace this for connection logic
if (thisNode == undefined && otherNode == undefined && isThisSource == false) {
console.warn("Impossible node connection called in EndConnects")
}
}

29
src/utils/cellStore.ts Normal file
View File

@@ -0,0 +1,29 @@
import {useSyncExternalStore} from "react";
type Unsub = () => void;
export type Cell<T> = {
get: () => T;
set: (next: T | ((prev: T) => T)) => void;
subscribe: (callback: () => void) => Unsub;
};
export function cell<T>(initial: T): Cell<T> {
let value = initial;
const listeners = new Set<() => void>();
return {
get: () => value,
set: (next) => {
value = typeof next === "function" ? (next as (v: T) => T)(value) : next;
for (const l of listeners) l();
},
subscribe: (callback) => {
listeners.add(callback);
return () => listeners.delete(callback);
},
};
}
export function useCell<T>(c: Cell<T>) {
return useSyncExternalStore(c.subscribe, c.get, c.get);
}

View File

@@ -0,0 +1,19 @@
/**
* Find the indices of all elements that occur more than once.
*
* @param array The array to search for duplicates.
* @returns An array of indices where an element occurs more than once, in no particular order.
*/
export default function duplicateIndices<T>(array: T[]): number[] {
const positions = new Map<T, number[]>();
array.forEach((value, i) => {
if (!positions.has(value)) positions.set(value, []);
positions.get(value)!.push(i);
});
// flatten all index lists with more than one element
return Array.from(positions.values())
.filter(idxs => idxs.length > 1)
.flat();
}

View File

@@ -0,0 +1,21 @@
/**
* Format a time duration like `HH:MM:SS.mmm`.
*
* @param durationMs time duration in milliseconds.
* @return formatted time string.
*/
export default function formatDuration(durationMs: number): string {
const isNegative = durationMs < 0;
if (isNegative) durationMs = -durationMs;
const hours = Math.floor(durationMs / 3600000);
const minutes = Math.floor((durationMs % 3600000) / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
const milliseconds = Math.floor(durationMs % 1000);
return (isNegative ? '-' : '') +
`${hours.toString().padStart(2, '0')}:` +
`${minutes.toString().padStart(2, '0')}:` +
`${seconds.toString().padStart(2, '0')}.` +
`${milliseconds.toString().padStart(3, '0')}`;
}

View File

@@ -0,0 +1,24 @@
export type PriorityFilterPredicate<T> = {
priority: number;
predicate: (element: T) => boolean | null; // The predicate and its priority are ignored if it returns null.
}
/**
* Applies a list of priority predicates to an element. For all predicates that don't return null, if the ones with the highest level return true, then this function returns true.
* @param element The element to apply the predicates to.
* @param predicates The list of predicates to apply.
*/
export function applyPriorityPredicates<T>(element: T, predicates: PriorityFilterPredicate<T>[]): boolean {
let highestPriority = -1;
let highestKeep = true;
for (const predicate of predicates) {
if (predicate.priority >= highestPriority) {
const predicateKeep = predicate.predicate(element);
if (predicateKeep === null) continue; // This predicate doesn't care about the element, so skip it
if (predicate.priority > highestPriority) highestKeep = true;
highestPriority = predicate.priority;
highestKeep = highestKeep && predicateKeep;
}
}
return highestKeep;
}