@luxonis/depthai-viewer-common 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,409 @@
1
+ import { parsePipeline, type Pipeline } from '@luxonis/depthai-pipeline-lib';
2
+ import { type PublicChannelData, VisualizerConnection } from '@luxonis/visualizer-protobuf';
3
+ // TODO: Outdated import. Replace after updating visualizer-protobuf
4
+ import type { VisualizerConnectionArgs } from '@luxonis/visualizer-protobuf/dist/lib/src/connection/connection.js';
5
+ import { DAI_SERVICE_NAME } from '../constants/connection.js';
6
+ import type { Topic } from '../hooks/connection.js';
7
+ import type { DecoderWorkerInput, DecoderWorkerOutput } from './decoder.worker.js';
8
+ import _ from 'lodash';
9
+ import { StreamTypes } from '../constants/streams.js';
10
+
11
+ export type DAIConnectionInitArgs = {
12
+ type: 'ws' | 'webrtc';
13
+ connectionUrl: string;
14
+ clientId: string;
15
+ token: string;
16
+ automaticTokenRefresh?: (token: string) => void;
17
+ } & {
18
+ enabledStreams: string[];
19
+ };
20
+
21
+ type DaiConnectionEventMap = {
22
+ open: () => void;
23
+ error: (error: MessageEvent<unknown> | ErrorEvent) => void;
24
+ close: () => void;
25
+ pipeline: (pipeline: Pipeline | null) => void;
26
+ topics: (topics: Topic[]) => void;
27
+ disconnect: (error: ErrorEvent) => void;
28
+ };
29
+
30
+ export type DAIService = keyof typeof DAI_SERVICE_NAME;
31
+
32
+ export class DAIConnection {
33
+ alive = true;
34
+ connection?: VisualizerConnection;
35
+
36
+ // biome-ignore lint/suspicious/noExplicitAny: <explanation>
37
+ protected listeners: Partial<Record<keyof DaiConnectionEventMap, any[]>> = {};
38
+ // biome-ignore lint/suspicious/noExplicitAny: Typed only on a usage level
39
+ protected serviceListeners: Record<string, (data: any) => void> = {};
40
+
41
+ protected enabledTopics: string[] = [];
42
+ protected previouslyEnabledTopics: string[] = [];
43
+
44
+ protected decoderWorker!: Worker;
45
+ protected allowedServices: Set<DAIService> = new Set();
46
+ protected activeServices: Set<DAIService> = new Set([]);
47
+ protected serviceInterval: NodeJS.Timeout | null = null;
48
+
49
+ protected topicsTouched = false;
50
+ protected channels: PublicChannelData[] = [];
51
+ protected groups: Record<string, string> = {};
52
+ protected topics: Topic[] = [];
53
+ protected pipeline: Pipeline | null = null;
54
+
55
+ protected recreateDecoderWorker() {
56
+ this.decoderWorker?.terminate();
57
+ this.decoderWorker = new Worker(new URL('./decoder.worker.js', import.meta.url), {
58
+ type: 'module',
59
+ } as WorkerOptions);
60
+
61
+ this.decoderWorker.addEventListener('error', (event) =>
62
+ console.error('Decoder Worker Error:', event.message, event),
63
+ );
64
+ this.decoderWorker.addEventListener('messageerror', (event) =>
65
+ console.error('Decoder Worker Error (message):', event.data, event),
66
+ );
67
+
68
+ this.decoderWorker.addEventListener(
69
+ 'message',
70
+ ({ data: { kind, data, name } }: MessageEvent<DecoderWorkerOutput>) => {
71
+ if (!data) {
72
+ console.error(`Data decoding for topic '${name}' failed`, { kind, name, data });
73
+ return;
74
+ }
75
+
76
+ if (kind === 'pipeline') {
77
+ // FIXME: It would be nice to be able to parse pipeline data here
78
+ // FIXME: However there's a problem with importing parsePipeline into a worker for some reason
79
+ const parsedPipeline = parsePipeline(data);
80
+
81
+ if (JSON.stringify(this.pipeline) !== JSON.stringify(data)) {
82
+ this.pipeline = parsedPipeline;
83
+ for (const listener of this.listeners.pipeline ?? []) {
84
+ listener(parsedPipeline);
85
+ }
86
+ }
87
+ } else if (name) {
88
+ this.serviceListeners[name]?.(data);
89
+ } else {
90
+ this.serviceListeners[data.name]?.(data);
91
+ }
92
+ },
93
+ );
94
+ }
95
+
96
+ protected reportTopics(type?: 'ws' | 'webrtc') {
97
+ if (!this.groups || !this.channels || !this.alive) {
98
+ return;
99
+ }
100
+
101
+ const topicList: (Omit<Topic, 'annotations' | 'extraAnnotations'> & {
102
+ group: string | null;
103
+ })[] = [];
104
+ const annotationGroups: Record<string, string[]> = {};
105
+ const annotations: string[] = [];
106
+
107
+ for (const channel of this.channels) {
108
+ const group = this.groups[channel.topic];
109
+
110
+ if (channel.kind === 'annotations') {
111
+ annotations.push(channel.topic);
112
+ if (group) {
113
+ annotationGroups[group] ??= [];
114
+ annotationGroups[group].push(channel.topic);
115
+ }
116
+
117
+ continue;
118
+ }
119
+
120
+ const shouldOpenDefaultStream =
121
+ (this.enabledTopics.length === 0 ||
122
+ (this.enabledTopics.length !== 0 &&
123
+ this.enabledTopics.includes(StreamTypes.NeuralNetwork))) &&
124
+ !this.topicsTouched;
125
+
126
+ if (shouldOpenDefaultStream) {
127
+ // If the connection is over WebSockets we want to open RAW stream as default stream
128
+ if (
129
+ type === 'ws' &&
130
+ _.isEqual(channel.topic.toLowerCase(), StreamTypes.Raw.toLowerCase())
131
+ ) {
132
+ this.topicsTouched = true;
133
+ this.enabledTopics.push(channel.topic);
134
+
135
+ if (!this.enabledTopics.includes(StreamTypes.NeuralNetwork)) {
136
+ this.enabledTopics.push(StreamTypes.NeuralNetwork);
137
+ }
138
+ // If the connection is over WebRTC we want to open ENCODED stream as default stream
139
+ } else if (
140
+ type === 'webrtc' &&
141
+ _.isEqual(channel.topic.toLowerCase(), StreamTypes.Encoded.toLowerCase())
142
+ ) {
143
+ this.topicsTouched = true;
144
+ this.enabledTopics.push(channel.topic);
145
+
146
+ if (!this.enabledTopics.includes(StreamTypes.NeuralNetwork)) {
147
+ this.enabledTopics.push(StreamTypes.NeuralNetwork);
148
+ }
149
+ }
150
+ }
151
+
152
+ topicList.push({
153
+ name: channel.topic,
154
+ kind: channel.kind,
155
+ enabled: this.enabledTopics.includes(channel.topic),
156
+ group: this.groups[channel.topic] ?? null,
157
+ });
158
+ }
159
+
160
+ const fullAnnotationGroups = Object.fromEntries(
161
+ Object.entries(annotationGroups).map(([group, topics]) => [
162
+ group,
163
+ {
164
+ topics,
165
+ extra: annotations.filter((annotation) => !topics.includes(annotation)),
166
+ },
167
+ ]),
168
+ );
169
+
170
+ const updatedTopics = topicList.map<Topic>((topic) => ({
171
+ ...topic,
172
+ annotations: fullAnnotationGroups[topic.group ?? '']?.topics ?? annotations,
173
+ extraAnnotations: fullAnnotationGroups[topic.group ?? '']?.extra ?? [],
174
+ }));
175
+
176
+ if (JSON.stringify(this.topics) !== JSON.stringify(updatedTopics)) {
177
+ const [oldTopics, newTopics] = updatedTopics.reduce<[Set<string>, Set<string>]>(
178
+ (sets, topic) => {
179
+ const set = sets[Number(topic.enabled)];
180
+ for (const name of [topic.name, ...topic.annotations, ...topic.extraAnnotations]) {
181
+ set.add(name);
182
+ }
183
+ return sets;
184
+ },
185
+ [new Set(), new Set()],
186
+ );
187
+
188
+ for (const annotation of newTopics) {
189
+ oldTopics.delete(annotation);
190
+ }
191
+
192
+ // TODO: Rewrite lib to control enabled annotations
193
+ // This setup subscribes to all annotations regardless of the enabled state
194
+ if (oldTopics.size !== 0) {
195
+ this.connection?.unsubscribe({ topics: [...oldTopics] });
196
+ }
197
+
198
+ if (newTopics.size !== 0) {
199
+ this.connection?.subscribe({ topics: [...newTopics] });
200
+ }
201
+
202
+ this.topics = updatedTopics;
203
+ for (const listener of this.listeners.topics ?? []) {
204
+ listener(updatedTopics);
205
+ }
206
+ }
207
+ }
208
+
209
+ protected pollServices() {
210
+ if (this.serviceInterval) {
211
+ clearInterval(this.serviceInterval);
212
+ }
213
+ this.serviceInterval = setInterval(() => {
214
+ for (const service of this.activeServices) {
215
+ this.fetchService(service);
216
+ }
217
+ }, 3_000);
218
+ }
219
+
220
+ init({ enabledStreams, ...args }: DAIConnectionInitArgs) {
221
+ if (enabledStreams && enabledStreams.length > 0) {
222
+ console.debug('Initializing connection with predefined streams', enabledStreams);
223
+ this.enabledTopics = enabledStreams;
224
+ }
225
+
226
+ this.recreateDecoderWorker();
227
+ this.setupConnection(args);
228
+ }
229
+
230
+ setupConnection({
231
+ type,
232
+ connectionUrl,
233
+ clientId,
234
+ token,
235
+ automaticTokenRefresh,
236
+ }: Pick<
237
+ DAIConnectionInitArgs,
238
+ 'type' | 'clientId' | 'connectionUrl' | 'token' | 'automaticTokenRefresh'
239
+ >) {
240
+ // TODO: Enable Luxonis ICE Servers
241
+ this.connection = new VisualizerConnection({
242
+ type: type as never,
243
+ data: (type === 'ws'
244
+ ? ({ connectionUrl } as (VisualizerConnectionArgs & {
245
+ type: 'ws';
246
+ })['data'])
247
+ : ({
248
+ config: {
249
+ clientId,
250
+ applicationIdentifier: '', // TODO:
251
+ authPayload: token,
252
+ signalingConnectionRetries: 5,
253
+ useLuxonisIceServers: true,
254
+ },
255
+ automaticTokenRefresh,
256
+ } as (VisualizerConnectionArgs & {
257
+ type: 'webrtc';
258
+ })['data'])) as never,
259
+ });
260
+
261
+ this.connection.on('open', () => {
262
+ if (!this.alive) return;
263
+ for (const listener of this.listeners.open ?? []) {
264
+ listener();
265
+ }
266
+ });
267
+
268
+ this.connection.on('close', () => {
269
+ if (!this.alive) return;
270
+ this.dispose();
271
+ });
272
+
273
+ this.connection.on('disconnect', (error) => {
274
+ for (const listener of this.listeners.disconnect ?? []) {
275
+ listener(error);
276
+ }
277
+ });
278
+
279
+ this.connection.on('services', (message) => {
280
+ if (!this.alive) return;
281
+ this.allowedServices.clear();
282
+ for (const service of message.data) {
283
+ this.allowedServices.add(service as DAIService);
284
+ if (
285
+ service === DAI_SERVICE_NAME.getDevices ||
286
+ service === DAI_SERVICE_NAME.pipelineSchema ||
287
+ service === DAI_SERVICE_NAME.topicGroups
288
+ ) {
289
+ this.fetchService(service as DAIService);
290
+ }
291
+ }
292
+ this.pollServices();
293
+ });
294
+
295
+ this.connection.on('channels', (message) => {
296
+ if (!this.alive) return;
297
+ this.channels = message.data;
298
+ this.reportTopics(type);
299
+
300
+ // If no default topic is enabled we want to enable at least one topic
301
+ if (this.enabledTopics.length === 0 && this.channels.at(0)?.topic) {
302
+ console.warn(
303
+ `No default topic is enabled - using ${this.channels.at(0)?.topic} as default topic.`,
304
+ );
305
+
306
+ this.toggleTopic(this.channels.at(0)!.topic);
307
+ }
308
+ });
309
+ }
310
+
311
+ async fetchService(
312
+ service: DAIService,
313
+ // biome-ignore lint/suspicious/noExplicitAny: Can't possibly be typed, we don't know request specifics
314
+ body?: Record<string, any> | string | number,
315
+ ) {
316
+ if (!this.alive) {
317
+ console.warn(`DAI: fetchService called while not alive for service "${service}"`);
318
+ return;
319
+ }
320
+
321
+ const canPoll = this.allowedServices.has(service);
322
+
323
+ if (canPoll) {
324
+ const data = await this.connection?.request({
325
+ name: DAI_SERVICE_NAME[service],
326
+ timeout: 60_000,
327
+ // biome-ignore lint/suspicious/noExplicitAny: <explanation>
328
+ body: body as Record<string, any>,
329
+ });
330
+
331
+ if (data) {
332
+ this.decoderWorker.postMessage(
333
+ {
334
+ kind: service === 'pipelineSchema' ? 'pipeline' : 'json',
335
+ name: service,
336
+ data,
337
+ } as DecoderWorkerInput,
338
+ [data.buffer],
339
+ );
340
+ }
341
+ } else {
342
+ console.warn(
343
+ `DAI: fetchService called for service "${service}" while it's not allowed to poll`,
344
+ );
345
+ }
346
+ }
347
+
348
+ setActiveServices(services: DAIService[]) {
349
+ this.activeServices = new Set(services);
350
+ }
351
+
352
+ dispose() {
353
+ this.alive = false;
354
+ this.decoderWorker.terminate();
355
+ if (this.serviceInterval) {
356
+ clearInterval(this.serviceInterval);
357
+ }
358
+ this.connection?.close();
359
+ for (const listener of this.listeners.close ?? []) {
360
+ listener();
361
+ }
362
+ }
363
+
364
+ handleKeyPress(key: string) {
365
+ void this.connection?.request({
366
+ name: DAI_SERVICE_NAME.keyPressed,
367
+ body: { key },
368
+ ignoreResponse: true,
369
+ });
370
+ }
371
+
372
+ toggleTopic(topic: string) {
373
+ this.topicsTouched = true;
374
+ const enabledIndex = this.enabledTopics.indexOf(topic);
375
+
376
+ if (enabledIndex === -1) {
377
+ this.enabledTopics.push(topic);
378
+ } else {
379
+ this.enabledTopics.splice(enabledIndex, 1);
380
+ }
381
+
382
+ this.reportTopics();
383
+ }
384
+
385
+ stopTopics() {
386
+ this.topicsTouched = false;
387
+ this.previouslyEnabledTopics = this.enabledTopics;
388
+
389
+ this.enabledTopics = [];
390
+ this.reportTopics();
391
+ }
392
+
393
+ restartTopics() {
394
+ this.topicsTouched = true;
395
+ this.enabledTopics = this.previouslyEnabledTopics;
396
+ this.previouslyEnabledTopics = [];
397
+
398
+ this.reportTopics();
399
+ }
400
+
401
+ on<T extends keyof DaiConnectionEventMap>(event: T, callback: DaiConnectionEventMap[T]) {
402
+ this.listeners[event] ??= [];
403
+ this.listeners[event].push(callback);
404
+ }
405
+
406
+ setOnService<T>(service: string, callback: (data: T) => void) {
407
+ this.serviceListeners[service] = callback;
408
+ }
409
+ }
@@ -0,0 +1,58 @@
1
+ import type { RawPipelinePayload } from '@luxonis/depthai-pipeline-lib';
2
+
3
+ function decodeService(response: DataView | null) {
4
+ if (!response) {
5
+ return null;
6
+ }
7
+
8
+ try {
9
+ const decoder = new TextDecoder('utf-8');
10
+ const decodedPayload = decoder.decode(response);
11
+
12
+ let parsedPayload = null;
13
+
14
+ try {
15
+ parsedPayload = JSON.parse(decodedPayload);
16
+ } catch (error) {
17
+ console.error('decoder.worker failed to parse message', { error, response });
18
+ }
19
+
20
+ // FIXME: It would be nice to be able to parse pipeline data here
21
+ // FIXME: However there's a problem with importing parsePipeline into a worker for some reason
22
+ //
23
+ //if (kind === 'pipeline') {
24
+ // parsed = parsePipeline(parsed);
25
+ //}
26
+
27
+ return parsedPayload;
28
+ } catch (error) {
29
+ console.error('Error decoding JSON:', error);
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export type DecoderWorkerInput = {
35
+ kind: 'pipeline' | 'json';
36
+ name?: string;
37
+ data: DataView;
38
+ };
39
+
40
+ export type DecoderWorkerOutput = { name?: string } & (
41
+ | {
42
+ kind: 'pipeline';
43
+ data: RawPipelinePayload;
44
+ }
45
+ | {
46
+ kind: 'json';
47
+ data: Record<string, string>;
48
+ }
49
+ );
50
+
51
+ self.addEventListener(
52
+ 'message',
53
+ ({ data: { kind, data, name } }: MessageEvent<DecoderWorkerInput>) => {
54
+ const decoded = decodeService(data);
55
+
56
+ self.postMessage({ kind, name, data: decoded } as DecoderWorkerOutput);
57
+ },
58
+ );
@@ -0,0 +1,204 @@
1
+ import { useToast } from '@luxonis/common-fe-components';
2
+ import type { Pipeline } from '@luxonis/depthai-pipeline-lib';
3
+ import type { VisualizerConnection } from '@luxonis/visualizer-protobuf';
4
+ import React from 'react';
5
+ import { StreamTypes } from '../constants/streams.js';
6
+ import { DAIConnection, type DAIService } from '../dai-connection/connection.js';
7
+ import { useFlatSearch } from './search.js';
8
+
9
+ export type Topic = {
10
+ name: string;
11
+ enabled: boolean;
12
+ kind: 'pointCloud' | 'image';
13
+ annotations: string[];
14
+ extraAnnotations: string[];
15
+ };
16
+
17
+ export type ConnectionData = {
18
+ connected: boolean;
19
+ daiConnection: DAIConnection | null;
20
+ connections: VisualizerConnection[];
21
+ pipeline: Pipeline | null | undefined;
22
+ isPipelineLoading: boolean;
23
+ setIsPipelineLoading: (isLoading: boolean) => void;
24
+ topics: Topic[];
25
+ toggleTopic: (topic: string) => void;
26
+ };
27
+
28
+ export type UseCreateConnectionArgs = {
29
+ type: 'webrtc' | 'ws';
30
+ connectionUrl: string;
31
+ token: string;
32
+ clientId: string;
33
+ activeServices: DAIService[];
34
+ onNewConnection?: (connection: DAIConnection) => void;
35
+ };
36
+
37
+ export function useCreateConnection({
38
+ type,
39
+ activeServices,
40
+ onNewConnection,
41
+ ...params
42
+ }: UseCreateConnectionArgs): ConnectionData {
43
+ const { updateSearch, search } = useFlatSearch({
44
+ defaultInit: { streams: [], cid: '', t: '' },
45
+ arrayFields: ['streams'],
46
+ });
47
+ const [connected, setConnected] = React.useState(false);
48
+ const [topics, setTopics] = React.useState<Topic[]>([]);
49
+ const [pipeline, setPipeline] = React.useState<Pipeline | null | undefined>(undefined);
50
+ const [isPipelineLoading, setIsPipelineLoading] = React.useState(false);
51
+
52
+ const [connection, setConnection] = React.useState<DAIConnection | null>(null);
53
+
54
+ const { toast } = useToast();
55
+ const handleGetCameraIntrinsics = () => {
56
+ if (connection) {
57
+ const getCameraIntrinsics = localStorage.getItem('neuralCameraIntrinsics');
58
+ if (!getCameraIntrinsics) {
59
+ connection.setOnService('getCameraIntrinsics', (data) => {
60
+ // @ts-ignore - The responce is not typed
61
+ localStorage.setItem('neuralCameraIntrinsics', JSON.stringify(data.data));
62
+ });
63
+ connection.fetchService('getCameraIntrinsics');
64
+ }
65
+ } else {
66
+ console.warn('Failed to get camera intrinsics, no connection');
67
+ }
68
+ };
69
+ handleGetCameraIntrinsics();
70
+
71
+ const refreshConnection = () => {
72
+ const newConnection = new DAIConnection();
73
+
74
+ newConnection.on('error', console.error);
75
+ newConnection.on('open', () => {
76
+ setConnected(true);
77
+ handleGetCameraIntrinsics();
78
+ setIsPipelineLoading(true);
79
+ });
80
+ newConnection.on('close', () => {
81
+ setConnected(false);
82
+ setTopics([]);
83
+ setPipeline(undefined);
84
+ setIsPipelineLoading(false);
85
+ });
86
+ newConnection.on('topics', setTopics);
87
+ newConnection.on('pipeline', (pipeline) => {
88
+ setPipeline(pipeline);
89
+ setIsPipelineLoading(false);
90
+ });
91
+ newConnection.on('disconnect', (_error) => {
92
+ toast({
93
+ description:
94
+ 'The link to the application frontend is not valid. You need to open the frontend from Luxonis Hub again.',
95
+ colorVariant: 'error',
96
+ duration: 'long',
97
+ name: 'connection-error',
98
+ });
99
+ setConnected(false);
100
+ setTopics([]);
101
+ setPipeline(undefined);
102
+ });
103
+ onNewConnection?.(newConnection);
104
+
105
+ if (newConnection.alive) {
106
+ newConnection.init({
107
+ type,
108
+ ...params,
109
+ automaticTokenRefresh: (token) => {
110
+ updateSearch('t', token);
111
+ },
112
+ enabledStreams: search.streams as string[],
113
+ });
114
+
115
+ setConnection(newConnection);
116
+ }
117
+ };
118
+
119
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `params` will always change on re-renders
120
+ React.useEffect(() => {
121
+ if (!connected) {
122
+ refreshConnection();
123
+ } else {
124
+ handleGetCameraIntrinsics();
125
+ }
126
+ }, [params.clientId, params.connectionUrl, params.token, onNewConnection, connected]);
127
+
128
+ React.useEffect(() => {
129
+ connection?.setActiveServices(activeServices);
130
+ }, [connection, activeServices]);
131
+
132
+ const connections = React.useMemo(
133
+ () => (connection?.connection ? [connection.connection] : []),
134
+ [connection],
135
+ );
136
+
137
+ React.useEffect(() => {
138
+ if (connected) {
139
+ const callback = (event: KeyboardEvent) => connection?.handleKeyPress(event.key);
140
+ window.addEventListener('keydown', callback);
141
+ return () => window.removeEventListener('keydown', callback);
142
+ }
143
+ }, [connected, connection]);
144
+
145
+ React.useEffect(() => {
146
+ if (topics.length > 0) {
147
+ if (topics.find((topic) => topic.name === StreamTypes.PointCloud)?.enabled) {
148
+ const pcColor = topics.find((t) => t.name === StreamTypes._PointCloudColor);
149
+ if (pcColor?.enabled === false) {
150
+ connection?.toggleTopic(StreamTypes._PointCloudColor);
151
+ } else if (
152
+ !pcColor &&
153
+ topics.find((t) => t.name === StreamTypes.Encoded)?.enabled === false
154
+ ) {
155
+ connection?.toggleTopic(StreamTypes.Encoded);
156
+ }
157
+ }
158
+ updateSearch(
159
+ 'streams',
160
+ topics.filter((topic) => topic.enabled).map((topic) => topic.name),
161
+ );
162
+ }
163
+ }, [topics, updateSearch]);
164
+
165
+ const toggleTopic = React.useCallback(
166
+ (topicName: string, isRawSteamEnabledManually?: boolean): void => {
167
+ if (
168
+ topicName === StreamTypes.PointCloud &&
169
+ topics.find((topic) => topic.name === topicName)?.enabled === false
170
+ ) {
171
+ const pcColor = topics.find((t) => t.name === StreamTypes._PointCloudColor);
172
+ if (!pcColor?.enabled) {
173
+ connection?.toggleTopic(StreamTypes._PointCloudColor);
174
+ } else if (!pcColor && topics.find((t) => t.name === StreamTypes.Encoded)) {
175
+ connection?.toggleTopic(StreamTypes.Encoded);
176
+ }
177
+ }
178
+ if (
179
+ topicName === StreamTypes.PointCloud &&
180
+ isRawSteamEnabledManually === false &&
181
+ topics.find((n) => n.name === 'Point Cloud')?.enabled
182
+ ) {
183
+ if (topics.find((n) => n.name === StreamTypes._PointCloudColor)) {
184
+ connection?.toggleTopic(StreamTypes._PointCloudColor);
185
+ } else if (topics.find((n) => n.name === StreamTypes.Encoded)) {
186
+ connection?.toggleTopic(StreamTypes.Encoded);
187
+ }
188
+ }
189
+ connection?.toggleTopic(topicName);
190
+ },
191
+ [connection],
192
+ );
193
+
194
+ return {
195
+ connected,
196
+ daiConnection: connection,
197
+ connections,
198
+ pipeline,
199
+ isPipelineLoading,
200
+ setIsPipelineLoading,
201
+ topics,
202
+ toggleTopic,
203
+ };
204
+ }
@@ -0,0 +1,25 @@
1
+ import React from 'react';
2
+ import { useLocation } from 'react-router-dom';
3
+ import { parseUrl as defaultParseUrl } from '../utils/url.js';
4
+
5
+ export function useNavigation(parseUrl?: (pathname: string) => { basePath: string; page: string }) {
6
+ const location = useLocation();
7
+
8
+ // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
9
+ const { page, basePath } = React.useMemo(
10
+ () => (parseUrl ?? defaultParseUrl)(location.pathname),
11
+ [location.pathname],
12
+ );
13
+
14
+ const makePath = React.useCallback(
15
+ (page: string, options: { noSearch?: boolean; rootPage?: string } = {}) => {
16
+ const { noSearch = false, rootPage = 'streams' } = options;
17
+ const target = page === rootPage ? '' : page;
18
+
19
+ return `/${basePath}${basePath ? '/' : ''}${target}${noSearch ? '' : location.search}`;
20
+ },
21
+ [basePath, location.search],
22
+ );
23
+
24
+ return { basePath, page, makePath };
25
+ }