Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions software/tracksight/frontend/app/live-data/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
"use client";

import LiveDataDashboard from "@/components/LiveDataDashboard";
import { DashboardLayoutProvider } from "@/lib/contexts/DashboardLayout";
import { LiveSignalDataStoreProvider } from "@/lib/contexts/LiveSignalDataStore";

export default function LiveDataPage() {
return (
<div className="w-screen">
<div className="w-screen pb-20">
<DashboardLayoutProvider>
<LiveDataDashboard />
<LiveSignalDataStoreProvider>
<LiveDataDashboard />
</LiveSignalDataStoreProvider>
</DashboardLayoutProvider>
</div>
);
}

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useRef } from "react";
import Widget from "@/components/widgets/Widget";
import { useDashboardLayout } from "@/lib/contexts/DashboardLayout";

// TODO(evan): Rename to just DataDashboard since it can be used for both live and historical data
function LiveDataDashboard() {
const { widgets } = useDashboardLayout();

Expand Down
2 changes: 1 addition & 1 deletion software/tracksight/frontend/components/SignalSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function SignalItemRenderer(props: { data: SignalMetadata; isSelected?: boolean
function SignalSelector(props: SignalSelectorProps) {
const { filter, selectedSignal, onSelect, buttonElement } = props;

const signalMetadata = useSignalMetadataList("*");
const signalMetadata = useSignalMetadataList(".*");

if (signalMetadata.isLoading || !signalMetadata.data) {
return <div>Loading...</div>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React, { RefObject, useRef } from "react";

import SignalSelector from "@/components/SignalSelector";
import useSignalMetadata from "@/lib/hooks/useSignalMetadata";
import { isEnumSignalMetadata } from "@/lib/types/Signal";
import { isEnumSignalMetadata, SignalMetadata } from "@/lib/types/Signal";

type EnumSignalSelectorProps = {
currentSignal: string;
Expand Down
36 changes: 26 additions & 10 deletions software/tracksight/frontend/components/widgets/EnumTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { WidgetData } from "@/lib/types/Widget";
import EnumSignalSelector from "@/components/widgets/EnumSignalSelector";
import { useDashboardLayout } from "@/lib/contexts/DashboardLayout";
import useSignalMetadata from "@/lib/hooks/useSignalMetadata";
import { useSignalDataStore } from "@/lib/contexts/SignalDataStore";
import { useEffect, useRef } from "react";
import { isEnumSignalMetadata } from "@/lib/types/Signal";

function EnumTimeline(props: WidgetData<"enumTimeline">) {
const { signals, options } = props;
Expand All @@ -13,6 +16,10 @@ function EnumTimeline(props: WidgetData<"enumTimeline">) {

const { editWidget } = useDashboardLayout();

const canvasRef = useRef<HTMLCanvasElement>(null);

const data = useSignalDataStore(signals[0]);

const signalMetadata = useSignalMetadata(signals[0] ?? "");

if (signalMetadata.isLoading) {
Expand Down Expand Up @@ -46,9 +53,19 @@ function EnumTimeline(props: WidgetData<"enumTimeline">) {
)
}

const enumOptions = Object.values(signalMetadata.data.enum?.items || {});
if (!isEnumSignalMetadata(signalMetadata.data)) {
return (
<div className="flex flex-col items-center">
Invalid signal type.
<p className="p-4 text-center text-sm">
The signal "{signals[0]}" is not an enum signal. Please select a valid enum signal.
</p>
</div>
);
}

const enumOptions = Object.values(signalMetadata.data.enum.items);

// TODO(evan): Implement this when the renderer is done
return (
<div className="flex flex-col gap-5">
<div className="px-8">
Expand All @@ -63,14 +80,13 @@ function EnumTimeline(props: WidgetData<"enumTimeline">) {
</div>

<div className="relative flex h-6 w-full">
{signals.map((_, i) => (
<div
className="w-full"
style={{
backgroundColor: colorPalette[i],
}}
/>
))}
<canvas
className="w-full"
style={{
backgroundColor: "lightgray",
}}
ref={canvasRef}
></canvas>
</div>

<LabelLegend signals={enumOptions} colorPalette={colorPalette} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const unsubscribeFromSignal = async (signalName: string) => {
throw new Error(`Failed to unsubscribe from signal: ${signalName}`);
}

return response.json();
return await response.json();
};

export { subscribeToSignal, unsubscribeFromSignal };
124 changes: 124 additions & 0 deletions software/tracksight/frontend/lib/contexts/LiveSignalDataStore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { CircularBuffer, SignalDataStoreProvider, type GenericSignalStore } from "@/lib/contexts/SignalDataStore";
import socket from "@/lib/realtime/socket";
import useUnsubscribeToSignal from "@/lib/mutations/useUnsubscribeToSignal";
import useSubscribeToSignal from "@/lib/mutations/useSubscribeToSignal";
import React, { memo, useRef } from "react";

const MAX_ELEMENTS_IN_BUFFER = 5000;

class LiveSignalDataStore implements GenericSignalStore {
private subscriberCounts: { [signalName: string]: number };
private liveData: { [signalName: string]: ReturnType<GenericSignalStore['getReferenceToSignal']> };

private subscribeToSignal: ReturnType<typeof useSubscribeToSignal> | null = null;
private unsubscribeFromSignal: ReturnType<typeof useUnsubscribeToSignal> | null = null;

// NOTE(evan): Might be unnecessary but it stops the double rendering creating multiple instances
private static instance: LiveSignalDataStore | null = null;

constructor() {
LiveSignalDataStore.instance = this;

this.subscriberCounts = {};
this.liveData = {};

socket.on("data", (payload) => {
const { name: signalName, timestamp, value } = payload;

if (!this.liveData[signalName]) {
console.error(`Received data for unsubscribed signal: ${signalName}`);

return;
}

this.liveData[signalName].data.timePoints.push(new Date(timestamp).getTime());
this.liveData[signalName].data.values.push(value);
});
}

static getInstance() {
if (!LiveSignalDataStore.instance) {
LiveSignalDataStore.instance = new LiveSignalDataStore();
}

return LiveSignalDataStore.instance;
}

updateMutations(subscribeToSignal: ReturnType<typeof useSubscribeToSignal>, unsubscribeFromSignal: ReturnType<typeof useUnsubscribeToSignal>) {
this.subscribeToSignal = subscribeToSignal;
this.unsubscribeFromSignal = unsubscribeFromSignal;
}

getReferenceToSignal(signalName: string) {
if (!this.liveData[signalName]) {
this.liveData[signalName] = {
data: {
timePoints: new CircularBuffer(MAX_ELEMENTS_IN_BUFFER),
values: new CircularBuffer(MAX_ELEMENTS_IN_BUFFER),
},
error: null,
isSubscribed: true,
}

this.subscriberCounts[signalName] = 0;

this.subscribeToSignal?.mutate(signalName, {
onError: (error) => {
if (!this.liveData[signalName]) return;

this.liveData[signalName].error = error;
}
});
}

this.subscriberCounts[signalName] += 1;

return this.liveData[signalName];
}

purgeReferenceToSignal(signalName: string) {
if (!this.subscriberCounts[signalName]) return;

this.subscriberCounts[signalName] -= 1;

if (this.subscriberCounts[signalName] > 0) return;

if (this.liveData[signalName]) {
this.liveData[signalName].isSubscribed = false;
}

this.unsubscribeFromSignal?.mutate(signalName, {
onSuccess: () => {
if (this.subscriberCounts[signalName] !== 0) return;

delete this.liveData[signalName];
delete this.subscriberCounts[signalName];
},
onError: (error) => {
console.error(`Error unsubscribing from signal ${signalName}:`, error);
if (this.subscriberCounts[signalName] !== 0) return;

this.subscriberCounts[signalName] = 1;
}
});
}
}

const LiveSignalDataStoreProvider = memo(({ children }: { children: React.ReactNode }) => {
const subscribeToSignalMutation = useSubscribeToSignal();
const unsubscribeFromSignalMutation = useUnsubscribeToSignal();

const liveSignalStore = useRef(LiveSignalDataStore.getInstance());

liveSignalStore.current.updateMutations(subscribeToSignalMutation, unsubscribeFromSignalMutation);

return (
<SignalDataStoreProvider signalStore={liveSignalStore}>
{children}
</SignalDataStoreProvider>
);
});

export { LiveSignalDataStoreProvider };


136 changes: 136 additions & 0 deletions software/tracksight/frontend/lib/contexts/SignalDataStore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"use client";

import { createContext, RefObject, ReactNode, useContext, useEffect, useRef } from "react";

export class CircularBuffer {
private buffer: Uint32Array;

private head: number = 0;
private length: number = 0;

constructor(size: number) {
this.buffer = new Uint32Array(size);
}

size() {
return this.length;
}

push(value: number) {
const writeIndex = (this.head + this.length) % this.buffer.length;
this.buffer[writeIndex] = value;

if (this.length < this.buffer.length) {
this.length++;
return;
}

this.head = (this.head + 1) % this.buffer.length;
}

get(index: number) {
if (index < 0 || index >= this.length) {
return undefined;
}

const bufferIndex = (this.head + index) % this.buffer.length;
return this.buffer[bufferIndex];
}
}

interface GenericSignalStore {
getReferenceToSignal: (signalName: string) => {
data: {
timePoints: CircularBuffer;
values: CircularBuffer;
},
error: unknown | null;
isSubscribed: boolean;
};

purgeReferenceToSignal: (signalName: string) => void;
};

type SignalDataStoreType = {
signalStore: RefObject<GenericSignalStore>;
};

const SignalDataStoreContext = createContext<SignalDataStoreType | null>(null);

function SignalDataStoreProvider({ children, signalStore }: { children: ReactNode, signalStore: SignalDataStoreType['signalStore'] }) {
return (
<SignalDataStoreContext.Provider
value={{ signalStore }}
>
{children}
</SignalDataStoreContext.Provider>
);
};

const useSignalDataStore = (signalName: string) => {
const context = useContext(SignalDataStoreContext);

if (context === null) {
throw new Error("useSignalDataStore must be used within a SignalDataStoreProvider");
}

const { signalStore } = context;
const cachedReferenceRef = useRef<ReturnType<GenericSignalStore['getReferenceToSignal']> | null>(null);

useEffect(() => {
return () => {
if (!signalStore.current) return;

signalStore.current.purgeReferenceToSignal(signalName);
};
}, [signalName, signalStore.current]);

useEffect(() => {
if (!signalStore.current) return;

cachedReferenceRef.current = signalStore.current.getReferenceToSignal(signalName);
}, [signalName, signalStore.current]);


return cachedReferenceRef;
}

const useSignalDataStores = (signalNames: string[]) => {
const context = useContext(SignalDataStoreContext);

if (context === null) {
throw new Error("useSignalDataStores must be used within a SignalDataStoreProvider");
}

const { signalStore } = context;
const cachedReferencesRef = useRef<Map<string, ReturnType<GenericSignalStore['getReferenceToSignal']>>>(new Map());

useEffect(() => {
return () => {
if (!signalStore.current) return;

signalNames.forEach((signalName) => {
signalStore.current!.purgeReferenceToSignal(signalName);
});
};
}, [signalNames, signalStore.current]);

useEffect(() => {
if (!signalStore.current) return;

signalNames.forEach((signalName) => {
if (!cachedReferencesRef.current.has(signalName)) {
const reference = signalStore.current!.getReferenceToSignal(signalName);
cachedReferencesRef.current.set(signalName, reference);
}
});
}, [signalNames, signalStore.current]);

return cachedReferencesRef;
}


export type { GenericSignalStore };

export { SignalDataStoreProvider, useSignalDataStore, useSignalDataStores };

Loading