diff --git a/packages/suite-base/src/components/PanelExtensionAdapter/PanelExtensionAdapter.tsx b/packages/suite-base/src/components/PanelExtensionAdapter/PanelExtensionAdapter.tsx index 2be03991d7..6e5b1ca4e5 100644 --- a/packages/suite-base/src/components/PanelExtensionAdapter/PanelExtensionAdapter.tsx +++ b/packages/suite-base/src/components/PanelExtensionAdapter/PanelExtensionAdapter.tsx @@ -62,10 +62,10 @@ import { assertNever } from "@lichtblick/suite-base/util/assertNever"; import { maybeCast } from "@lichtblick/suite-base/util/maybeCast"; import { PanelConfigVersionError } from "./PanelConfigVersionError"; -import { createMessageRangeIterator } from "./messageRangeIterator"; import { RenderStateConfig, initRenderStateBuilder } from "./renderState"; import { BuiltinPanelExtensionContext, MessageConverterAlertHandler } from "./types"; import { useSharedPanelState } from "./useSharedPanelState"; +import { useSubscribeMessageRange } from "./useSubscribeMessageRange"; const log = Logger.getLogger(__filename); @@ -134,7 +134,6 @@ function PanelExtensionAdapter( getMetadata, sortedTopics, sortedServices, - getBatchIterator, } = messagePipelineContext; const { capabilities, profile: dataSourceProfile, presence: playerPresence } = playerState; @@ -202,6 +201,8 @@ function PanelExtensionAdapter( [setAlert], ); + const subscribeMessageRange = useSubscribeMessageRange(emitMessageConverterAlert); + // Register handlers to update the app settings we subscribe to useEffect(() => { const handlers = new Map void>(); @@ -606,32 +607,11 @@ function PanelExtensionAdapter( * - Error handling is still being refined * - API surface may change based on testing feedback */ - unstable_subscribeMessageRange({ topic, convertTo, onNewRangeIterator }) { + unstable_subscribeMessageRange(args) { if (!isMounted()) { return () => {}; } - - const rawBatchIterator = getBatchIterator(topic); - if (!rawBatchIterator) { - // If no batch iterator is available, just return an empty cleanup function - return () => {}; - } - - const { iterable: messageEventIterable, cancel } = createMessageRangeIterator({ - topic, - convertTo, - rawBatchIterator, - sortedTopics, - messageConverters: messageConverters ?? [], - emitAlert: emitMessageConverterAlert, - }); - - // Call the callback with the processed iterable - onNewRangeIterator(messageEventIterable).catch((err: unknown) => { - log.error("Error in onNewRangeIterator callback:", err); - }); - - return cancel; + return subscribeMessageRange(args); }, unstable_setMessagePathDropConfig(dropConfig) { @@ -660,7 +640,7 @@ function PanelExtensionAdapter( updatePanelSettingsTree, setDefaultPanelTitle, setMessagePathDropConfig, - emitMessageConverterAlert, + subscribeMessageRange, ]); const panelContainerRef = useRef(ReactNull); diff --git a/packages/suite-base/src/components/PanelExtensionAdapter/index.ts b/packages/suite-base/src/components/PanelExtensionAdapter/index.ts index 29efb6bf8e..389c157755 100644 --- a/packages/suite-base/src/components/PanelExtensionAdapter/index.ts +++ b/packages/suite-base/src/components/PanelExtensionAdapter/index.ts @@ -6,6 +6,7 @@ // file, You can obtain one at http://mozilla.org/MPL/2.0/ export { default as PanelExtensionAdapter } from "./PanelExtensionAdapter"; +export { useSubscribeMessageRange } from "./useSubscribeMessageRange"; export type { Asset, BuiltinPanelExtensionContext, diff --git a/packages/suite-base/src/components/PanelExtensionAdapter/useSubscribeMessageRange.ts b/packages/suite-base/src/components/PanelExtensionAdapter/useSubscribeMessageRange.ts new file mode 100644 index 0000000000..6b633f2355 --- /dev/null +++ b/packages/suite-base/src/components/PanelExtensionAdapter/useSubscribeMessageRange.ts @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (C) 2023-2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { useCallback, useRef } from "react"; + +import Logger from "@lichtblick/log"; +import { SubscribeMessageRangeArgs } from "@lichtblick/suite"; +import { useMessagePipelineGetter } from "@lichtblick/suite-base/components/MessagePipeline"; +import { + ExtensionCatalog, + useExtensionCatalog, +} from "@lichtblick/suite-base/context/ExtensionCatalogContext"; + +import { createMessageRangeIterator } from "./messageRangeIterator"; +import { MessageConverterAlertHandler } from "./types"; + +const log = Logger.getLogger(__filename); + +const selectInstalledMessageConverters = (state: ExtensionCatalog) => + state.installedMessageConverters; + +export type UseSubscribeMessageRange = (args: SubscribeMessageRangeArgs) => () => void; + +/** + * Returns a stable callback that can be used to subscribe to a message range for a topic. + * This centralizes the logic of `unstable_subscribeMessageRange` so it can be used both by + * PanelExtensionAdapter and directly by built-in panels (e.g. Plot) without requiring migration + * to the PanelExtensionContext API. + */ +export function useSubscribeMessageRange( + emitAlert?: MessageConverterAlertHandler, +): UseSubscribeMessageRange { + // useMessagePipelineGetter returns a stable getter, so it's safe to call it inside the callback without adding it to dependencies. + const getMessagePipelineContext = useMessagePipelineGetter(); + + // Keep messageConverters in a ref so changing converters don't invalidate the callback. + const messageConverters = useExtensionCatalog(selectInstalledMessageConverters); + const messageConvertersRef = useRef(messageConverters); + messageConvertersRef.current = messageConverters; + + // Similarly keep emitAlert in a ref so the caller can update it without breaking stability. + const emitAlertRef = useRef(emitAlert); + emitAlertRef.current = emitAlert; + + return useCallback( + ({ topic, convertTo, onNewRangeIterator }: SubscribeMessageRangeArgs) => { + const { sortedTopics, getBatchIterator } = getMessagePipelineContext(); + + const rawBatchIterator = getBatchIterator(topic); + if (!rawBatchIterator) { + return () => {}; + } + + const { iterable: messageEventIterable, cancel } = createMessageRangeIterator({ + topic, + convertTo, + rawBatchIterator, + sortedTopics, + messageConverters: messageConvertersRef.current ?? [], + emitAlert: emitAlertRef.current, + }); + + onNewRangeIterator(messageEventIterable).catch((err: unknown) => { + log.error("Error in useSubscribeMessageRange onNewRangeIterator:", err); + }); + + return cancel; + }, + [getMessagePipelineContext], // getMessagePipelineContext is already stable, but listed for clarity + ); +} diff --git a/packages/suite-base/src/components/PanelExtensionAdapter/useSubscriberMessageRange.test.ts b/packages/suite-base/src/components/PanelExtensionAdapter/useSubscriberMessageRange.test.ts new file mode 100644 index 0000000000..5cf2913f7f --- /dev/null +++ b/packages/suite-base/src/components/PanelExtensionAdapter/useSubscriberMessageRange.test.ts @@ -0,0 +1,114 @@ +/** @jest-environment jsdom */ + +// SPDX-FileCopyrightText: Copyright (C) 2023-2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +// SPDX-License-Identifier: MPL-2.0 + +import { renderHook, act } from "@testing-library/react"; + +import { MessageEvent } from "@lichtblick/suite"; +import { useMessagePipelineGetter } from "@lichtblick/suite-base/components/MessagePipeline"; +import { useExtensionCatalog } from "@lichtblick/suite-base/context/ExtensionCatalogContext"; +import { BasicBuilder } from "@lichtblick/test-builders"; + +import { createMessageRangeIterator } from "./messageRangeIterator"; +import { useSubscribeMessageRange } from "./useSubscribeMessageRange"; + +jest.mock("@lichtblick/suite-base/components/MessagePipeline", () => ({ + useMessagePipelineGetter: jest.fn(), +})); + +jest.mock("@lichtblick/suite-base/context/ExtensionCatalogContext", () => ({ + useExtensionCatalog: jest.fn(), +})); + +jest.mock("./messageRangeIterator", () => ({ + createMessageRangeIterator: jest.fn(), +})); + +const mockUseMessagePipelineGetter = useMessagePipelineGetter as jest.Mock; +const mockUseExtensionCatalog = useExtensionCatalog as jest.Mock; +const mockCreateMessageRangeIterator = createMessageRangeIterator as jest.Mock; + +describe("useSubscribeMessageRange", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseExtensionCatalog.mockReturnValue([]); + }); + + it("does not call onNewRangeIterator when batch iterator is unavailable", () => { + // Given + mockUseMessagePipelineGetter.mockReturnValue( + jest.fn().mockReturnValue({ + sortedTopics: [], + getBatchIterator: jest.fn().mockReturnValue(undefined), + }), + ); + const onNewRangeIterator = jest.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useSubscribeMessageRange()); + + // When + act(() => { + result.current({ topic: BasicBuilder.string(), onNewRangeIterator }); + }); + + // Then + expect(onNewRangeIterator).not.toHaveBeenCalled(); + }); + + it("returns a callable cancel function when batch iterator is unavailable", () => { + // Given + mockUseMessagePipelineGetter.mockReturnValue( + jest.fn().mockReturnValue({ + sortedTopics: [], + getBatchIterator: jest.fn().mockReturnValue(undefined), + }), + ); + const onNewRangeIterator = jest.fn().mockResolvedValue(async () => {}); + const { result } = renderHook(() => useSubscribeMessageRange()); + let cancel!: () => void; + + // When + act(() => { + cancel = result.current({ topic: BasicBuilder.string(), onNewRangeIterator }); + }); + + // Then + expect(() => { + cancel(); + }).not.toThrow(); + }); + + it("calls onNewRangeIterator with the iterable when batch iterator is available", () => { + // Given + const topic = BasicBuilder.string(); + const mockIterable: AsyncIterable = { [Symbol.asyncIterator]: jest.fn() }; + const mockCancel = jest.fn(); + let cancel!: () => void; + const mockBatchIterator = { [Symbol.asyncIterator]: jest.fn() }; + const onNewRangeIterator = jest.fn().mockResolvedValue(async () => {}); + mockCreateMessageRangeIterator.mockReturnValue({ iterable: mockIterable, cancel: mockCancel }); + const mockGetBatchIterator = jest.fn().mockReturnValue(mockBatchIterator); + mockUseMessagePipelineGetter.mockReturnValue( + jest.fn().mockReturnValue({ sortedTopics: [], getBatchIterator: mockGetBatchIterator }), + ); + + const { result } = renderHook(() => useSubscribeMessageRange()); + + // When + act(() => { + cancel = result.current({ topic, onNewRangeIterator }); + }); + + // Then + expect(mockGetBatchIterator).toHaveBeenCalledWith(topic); + expect(onNewRangeIterator).toHaveBeenCalledWith(mockIterable); + expect(cancel).toBe(mockCancel); + expect(mockCreateMessageRangeIterator).toHaveBeenCalledWith( + expect.objectContaining({ + topic, + rawBatchIterator: mockBatchIterator, + sortedTopics: [], + }), + ); + }); +}); diff --git a/packages/suite-base/src/panels/Plot/Plot.test.tsx b/packages/suite-base/src/panels/Plot/Plot.test.tsx index be892efe10..3c3a5228b5 100644 --- a/packages/suite-base/src/panels/Plot/Plot.test.tsx +++ b/packages/suite-base/src/panels/Plot/Plot.test.tsx @@ -25,6 +25,11 @@ jest.mock("@lichtblick/suite-base/components/PanelContext", () => ({ const mockGetMessagePipelineState = jest.fn(); const mockSubscribeMessagePipeline = jest.fn(); +const mockSubscribeMessageRange = jest.fn(); +jest.mock("@lichtblick/suite-base/components/PanelExtensionAdapter", () => ({ + useSubscribeMessageRange: () => mockSubscribeMessageRange, +})); + jest.mock("@lichtblick/suite-base/components/MessagePipeline", () => ({ useMessagePipelineGetter: () => mockGetMessagePipelineState, useMessagePipelineSubscribe: () => mockSubscribeMessagePipeline, @@ -88,7 +93,9 @@ jest.mock("./hooks/usePlotInteractionHandlers", () => ({ let mockCoordinatorInstance: any; const mockPlotCoordinatorCtor = jest.fn(); jest.mock("./PlotCoordinator", () => ({ - PlotCoordinator: jest.fn((renderer, builder) => mockPlotCoordinatorCtor(renderer, builder)), + PlotCoordinator: jest.fn((renderer, builder, subscribeMessageRange) => + mockPlotCoordinatorCtor(renderer, builder, subscribeMessageRange), + ), })); const rendererStub = { id: "renderer" } as any; @@ -312,7 +319,11 @@ describe("Plot Component", () => { unmount(); // Then - expect(mockPlotCoordinatorCtor).toHaveBeenCalledWith(rendererStub, datasetsBuilderStub); + expect(mockPlotCoordinatorCtor).toHaveBeenCalledWith( + rendererStub, + datasetsBuilderStub, + mockSubscribeMessageRange, + ); expect(mockCoordinatorInstance.setSize).toHaveBeenCalledWith({ width: expect.any(Number), height: expect.any(Number), diff --git a/packages/suite-base/src/panels/Plot/Plot.tsx b/packages/suite-base/src/panels/Plot/Plot.tsx index 701958782a..201fd9655f 100644 --- a/packages/suite-base/src/panels/Plot/Plot.tsx +++ b/packages/suite-base/src/panels/Plot/Plot.tsx @@ -19,6 +19,7 @@ import { } from "@lichtblick/suite-base/components/MessagePipeline"; import { usePanelContext } from "@lichtblick/suite-base/components/PanelContext"; import { PanelContextMenu } from "@lichtblick/suite-base/components/PanelContextMenu"; +import { useSubscribeMessageRange } from "@lichtblick/suite-base/components/PanelExtensionAdapter"; import PanelToolbar from "@lichtblick/suite-base/components/PanelToolbar"; import { PANEL_TOOLBAR_MIN_HEIGHT } from "@lichtblick/suite-base/components/PanelToolbar/constants"; import Stack from "@lichtblick/suite-base/components/Stack"; @@ -69,6 +70,7 @@ const Plot = (props: PlotProps): React.JSX.Element => { const { globalVariables } = useGlobalVariables(); const getMessagePipelineState = useMessagePipelineGetter(); const subscribeMessagePipeline = useMessagePipelineSubscribe(); + const subscribeMessageRange = useSubscribeMessageRange(); const { onMouseMove, @@ -79,7 +81,7 @@ const Plot = (props: PlotProps): React.JSX.Element => { onClickPath, focusedPath, keyDownHandlers, - keyUphandlers, + keyUpHandlers, getPanelContextMenuItems, } = usePlotInteractionHandlers({ config, @@ -164,7 +166,7 @@ const Plot = (props: PlotProps): React.JSX.Element => { const contentRect = canvasDiv.getBoundingClientRect(); - const plotCoordinator = new PlotCoordinator(renderer, datasetsBuilder); + const plotCoordinator = new PlotCoordinator(renderer, datasetsBuilder, subscribeMessageRange); setCoordinator(plotCoordinator); plotCoordinator.setSize({ @@ -188,7 +190,7 @@ const Plot = (props: PlotProps): React.JSX.Element => { resizeObserver.disconnect(); plotCoordinator.destroy(); }; - }, [canvasDiv, datasetsBuilder, renderer]); + }, [canvasDiv, datasetsBuilder, renderer, subscribeMessageRange]); const numSeries = config.paths.length; const tooltipContent = useMemo(() => { @@ -287,7 +289,7 @@ const Plot = (props: PlotProps): React.JSX.Element => { )} - + ); }; diff --git a/packages/suite-base/src/panels/Plot/PlotCoordinator.test.ts b/packages/suite-base/src/panels/Plot/PlotCoordinator.test.ts index 24d756651c..41a9001015 100644 --- a/packages/suite-base/src/panels/Plot/PlotCoordinator.test.ts +++ b/packages/suite-base/src/panels/Plot/PlotCoordinator.test.ts @@ -8,9 +8,9 @@ import { parseMessagePath } from "@lichtblick/message-path"; import { simpleGetMessagePathDataItems } from "@lichtblick/suite-base/components/MessagePathSyntax/simpleGetMessagePathDataItems"; import { stringifyMessagePath } from "@lichtblick/suite-base/components/MessagePathSyntax/stringifyRosPath"; import { fillInGlobalVariablesInPath } from "@lichtblick/suite-base/components/MessagePathSyntax/useCachedGetMessagePathDataItems"; +import { UseSubscribeMessageRange } from "@lichtblick/suite-base/components/PanelExtensionAdapter/useSubscribeMessageRange"; import { InteractionEvent, Scale } from "@lichtblick/suite-base/panels/Plot/types"; import { PlotXAxisVal } from "@lichtblick/suite-base/panels/Plot/utils/config"; -import { MessageBlock } from "@lichtblick/suite-base/players/types"; import PlayerBuilder from "@lichtblick/suite-base/testing/builders/PlayerBuilder"; import PlotBuilder from "@lichtblick/suite-base/testing/builders/PlotBuilder"; import RosTimeBuilder from "@lichtblick/suite-base/testing/builders/RosTimeBuilder"; @@ -19,7 +19,8 @@ import { BasicBuilder } from "@lichtblick/test-builders"; import { OffscreenCanvasRenderer } from "./OffscreenCanvasRenderer"; import { PlotCoordinator } from "./PlotCoordinator"; -import { IDatasetsBuilder, SeriesItem } from "./builders/IDatasetsBuilder"; +import { IDatasetsBuilder, SeriesConfigKey, SeriesItem } from "./builders/IDatasetsBuilder"; +import { pathToSubscribePayload } from "./utils/subscription"; jest.mock("./OffscreenCanvasRenderer"); jest.mock("./builders/IDatasetsBuilder"); @@ -60,10 +61,20 @@ jest.mock("@lichtblick/suite-base/components/MessagePathSyntax/stringifyRosPath" stringifyMessagePath: jest.fn(), })); +jest.mock("./utils/subscription", () => ({ + pathToSubscribePayload: jest.fn().mockReturnValue(undefined), +})); + +const mockSubscribeMessageRange = jest.fn(); +jest.mock("@lichtblick/suite-base/components/PanelExtensionAdapter", () => ({ + useSubscribeMessageRange: () => mockSubscribeMessageRange, +})); + describe("PlotCoordinator", () => { let renderer: jest.Mocked; let datasetsBuilder: jest.Mocked; let plotCoordinator: PlotCoordinator; + let subscribeMessageRange: UseSubscribeMessageRange; beforeEach(() => { const canvas = new OffscreenCanvas(500, 500); @@ -77,10 +88,10 @@ describe("PlotCoordinator", () => { pathsWithMismatchedDataLengths: [], }); datasetsBuilder.setSeries = jest.fn(); - datasetsBuilder.handleBlocks = jest.fn().mockResolvedValue(undefined); datasetsBuilder.getCsvData = jest.fn().mockResolvedValue([]); - plotCoordinator = new PlotCoordinator(renderer, datasetsBuilder); + subscribeMessageRange = mockSubscribeMessageRange; + plotCoordinator = new PlotCoordinator(renderer, datasetsBuilder, subscribeMessageRange); }); afterEach(() => { @@ -118,7 +129,7 @@ describe("PlotCoordinator", () => { it("should return immediately if plotCoordinator is destroyed", () => { const state = PlayerBuilder.playerState(); - jest.spyOn(plotCoordinator as any, "isDestroyed").mockReturnValue(true); + plotCoordinator.destroy(); const handlePlayerStateSpy = jest.spyOn(datasetsBuilder, "handlePlayerState"); const updateSpy = jest.spyOn(renderer, "update"); @@ -196,6 +207,96 @@ describe("PlotCoordinator", () => { }); }); + describe("topic range subscriptions", () => { + beforeEach(() => { + (pathToSubscribePayload as jest.Mock).mockReturnValue({ topic: "/foo", preloadType: "full" }); + datasetsBuilder.handleMessageRange = jest.fn(); + mockSubscribeMessageRange.mockReturnValue(jest.fn()); + }); + + it("groups multiple series with the same topic into a single subscription", () => { + // Given + const topic = "/foo"; + plotCoordinator["series"] = [ + { + parsed: { topicName: topic, messagePath: [] }, + key: "0:receiveTime:/foo.x" as SeriesConfigKey, + configIndex: 0, + timestampMethod: "receiveTime", + }, + { + parsed: { topicName: topic, messagePath: [] }, + key: "1:receiveTime:/foo.y" as SeriesConfigKey, + configIndex: 1, + timestampMethod: "receiveTime", + }, + ] as unknown as SeriesItem[]; + const state = PlayerBuilder.playerState({ activeData: PlayerBuilder.activeData() }); + + // When + plotCoordinator.handlePlayerState(state); + + // Then + expect(mockSubscribeMessageRange).toHaveBeenCalledTimes(1); + expect(mockSubscribeMessageRange).toHaveBeenCalledWith(expect.objectContaining({ topic })); + }); + + it("cancels subscription for a topic removed from series", () => { + // Given — first call subscribes /foo + const cancelFoo = jest.fn(); + mockSubscribeMessageRange.mockReturnValue(cancelFoo); + plotCoordinator["series"] = [ + { + parsed: { topicName: "/foo", messagePath: [] }, + key: "0:receiveTime:/foo.val" as SeriesConfigKey, + configIndex: 0, + timestampMethod: "receiveTime", + }, + ] as unknown as SeriesItem[]; + const state = PlayerBuilder.playerState({ activeData: PlayerBuilder.activeData() }); + plotCoordinator.handlePlayerState(state); + + // When — second call with /bar only + mockSubscribeMessageRange.mockClear(); + plotCoordinator["series"] = [ + { + parsed: { topicName: "/bar", messagePath: [] }, + key: "0:receiveTime:/bar.val" as SeriesConfigKey, + configIndex: 0, + timestampMethod: "receiveTime", + }, + ] as unknown as SeriesItem[]; + plotCoordinator.handlePlayerState(state); + + // Then + expect(cancelFoo).toHaveBeenCalled(); + expect(mockSubscribeMessageRange).toHaveBeenCalledWith( + expect.objectContaining({ topic: "/bar" }), + ); + }); + + it("does not re-subscribe when the same topic and keys are unchanged", () => { + // Given + plotCoordinator["series"] = [ + { + parsed: { topicName: "/foo", messagePath: [] }, + key: "0:receiveTime:/foo.val" as SeriesConfigKey, + configIndex: 0, + timestampMethod: "receiveTime", + }, + ] as unknown as SeriesItem[]; + const state = PlayerBuilder.playerState({ activeData: PlayerBuilder.activeData() }); + plotCoordinator.handlePlayerState(state); + mockSubscribeMessageRange.mockClear(); + + // When — same series, same state + plotCoordinator.handlePlayerState(state); + + // Then — no new subscription opened + expect(mockSubscribeMessageRange).not.toHaveBeenCalled(); + }); + }); + describe("destroy", () => { it("should set 'destroyed' to true when calling destroy", () => { plotCoordinator.destroy(); @@ -205,6 +306,15 @@ describe("PlotCoordinator", () => { }); describe("dispatchRender", () => { + it("should return immediately if plotCoordinator is destroyed", async () => { + plotCoordinator.destroy(); + + await plotCoordinator["dispatchRender"](); + + const updateSpy = jest.spyOn(renderer, "update"); + expect(updateSpy).not.toHaveBeenCalled(); + }); + it("should call 'update' on the renderer when dispatching render", async () => { renderer.update.mockResolvedValue({ x: { min: 0, max: 10 }, y: { min: 0, max: 10 } }); @@ -280,20 +390,19 @@ describe("PlotCoordinator", () => { }); }); - describe("dispatchBlocks", () => { - it("should process and store message blocks correctly", async () => { - datasetsBuilder.handleBlocks = jest.fn().mockResolvedValue(undefined); - const blocks = [{}] as MessageBlock[]; - const startTime = RosTimeBuilder.time(); + describe("handleConfig", () => { + it("should return immediately if plotCoordinator is destroyed", () => { + const config = PlotBuilder.config({ + xAxisVal: "timestamp", + followingViewWidth: 10, + paths: [], + }); - await plotCoordinator["dispatchBlocks"](startTime, blocks); + plotCoordinator.destroy(); - const handleBlocksSpyOn = jest.spyOn(datasetsBuilder, "handleBlocks"); - expect(handleBlocksSpyOn).toHaveBeenCalled(); + plotCoordinator.handleConfig(config, "light", {}); + expect(plotCoordinator["isTimeseriesPlot"]).toBe(false); }); - }); - - describe("handleConfig", () => { it("should set isTimeseriesPlot to true when xAxisVal is 'timestamp'", () => { const config = PlotBuilder.config({ xAxisVal: "timestamp", diff --git a/packages/suite-base/src/panels/Plot/PlotCoordinator.ts b/packages/suite-base/src/panels/Plot/PlotCoordinator.ts index 36806ced17..0572933110 100644 --- a/packages/suite-base/src/panels/Plot/PlotCoordinator.ts +++ b/packages/suite-base/src/panels/Plot/PlotCoordinator.ts @@ -16,11 +16,11 @@ import { Immutable, Time } from "@lichtblick/suite"; import { simpleGetMessagePathDataItems } from "@lichtblick/suite-base/components/MessagePathSyntax/simpleGetMessagePathDataItems"; import { stringifyMessagePath } from "@lichtblick/suite-base/components/MessagePathSyntax/stringifyRosPath"; import { fillInGlobalVariablesInPath } from "@lichtblick/suite-base/components/MessagePathSyntax/useCachedGetMessagePathDataItems"; +import { UseSubscribeMessageRange } from "@lichtblick/suite-base/components/PanelExtensionAdapter/useSubscribeMessageRange"; import { Bounds1D } from "@lichtblick/suite-base/components/TimeBasedChart/types"; import { GlobalVariables } from "@lichtblick/suite-base/hooks/useGlobalVariables"; -import { MessageBlock, PlayerState } from "@lichtblick/suite-base/players/types"; +import { PlayerState } from "@lichtblick/suite-base/players/types"; import { Bounds } from "@lichtblick/suite-base/types/Bounds"; -import delay from "@lichtblick/suite-base/util/delay"; import { getContrastColor, getLineColor } from "@lichtblick/suite-base/util/plotColors"; import { OffscreenCanvasRenderer } from "./OffscreenCanvasRenderer"; @@ -40,12 +40,13 @@ import { UpdateAction, } from "./types"; import { isReferenceLinePlotPathType, PlotConfig } from "./utils/config"; +import { pathToSubscribePayload } from "./utils/subscription"; const replaceUndefinedWithEmptyDataset = (dataset: Dataset | undefined) => dataset ?? { data: [] }; /** * PlotCoordinator interfaces commands and updates between the dataset builder and the chart - * renderer. + * renderer. Uses subscribeMessageRange for full dataset history. */ export class PlotCoordinator extends EventEmitter { private renderer: OffscreenCanvasRenderer; @@ -74,22 +75,39 @@ export class PlotCoordinator extends EventEmitter { private queueDispatchRender = debouncePromise(this.dispatchRender.bind(this)); private queueDispatchDownsample = debouncePromise(this.dispatchDownsample.bind(this)); private queueDatasetsRender = debouncePromise(this.dispatchDatasetsRender.bind(this)); - private queueBlocks = debouncePromise(this.dispatchBlocks.bind(this)); private destroyed = false; - private latestBlocks?: Immutable<(MessageBlock | undefined)[]>; - public constructor(renderer: OffscreenCanvasRenderer, builder: IDatasetsBuilder) { + private readonly subscribeMessageRange: UseSubscribeMessageRange; + private readonly rangeSubscriptionCancels = new Map< + string, + { cancel: () => void; seriesKeys: ReadonlySet } + >(); + private startTime: Immutable