Skip to content
Open
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
9 changes: 9 additions & 0 deletions packages/broadcast/src/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
GetFiltersMethod,
GetGraphDatasetMethod,
GetGraphMethod,
GetSelectionMethod,
GetVersionMethod,
ImportGraphMethod,
MergeAppearanceMethod,
Expand All @@ -26,9 +27,11 @@ import {
MethodBroadcastMessage,
PingMethod,
ReplyMessage,
SerializedSelectionState,
SetAppearanceMethod,
SetFiltersMethod,
SetGraphDatasetMethod,
SetSelectionMethod,
TypedEventEmitter,
} from "./types";

Expand Down Expand Up @@ -142,6 +145,12 @@ export class GephiLiteDriver extends TypedEventEmitter<GephiLiteEvents> {
setFilters(filters: FiltersState) {
return this.callMethod<SetFiltersMethod>("setFilters", filters);
}
getSelection() {
return this.callMethod<GetSelectionMethod>("getSelection");
}
setSelection(selection: SerializedSelectionState) {
return this.callMethod<SetSelectionMethod>("setSelection", selection);
}
getWindow() {
return this.window;
}
Expand Down
51 changes: 38 additions & 13 deletions packages/broadcast/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@ import { AppearanceState, FiltersState, SerializedGraphDataset } from "@gephi/ge
import EventEmitter from "events";
import { SerializedGraph } from "graphology-types";

/**
* A vendor-neutral, JSON-serialisable description of the current selection.
* Gephi Lite's own internal selection model only ever has one active item
* type at a time (nodes XOR edges) - this broadcast-facing shape allows both
* arrays so external callers never need to know that implementation detail.
* Kept independent from any internal Gephi Lite type, same spirit as
* SerializedGraphDataset vs. GraphDataset.
*/
export interface SerializedSelectionState {
nodeIds: string[];
edgeIds: string[];
}

/**
* Helper types:
* *************
Expand All @@ -10,24 +23,28 @@ import { SerializedGraph } from "graphology-types";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Listener = (...args: any[]) => void;
type EventsMapping = Record<string, Listener>;
// Events are declared as `eventName(): DataType` (data type carried via the return type, see
// GephiLiteEventData below), so a listener actually receiving that data must be typed from
// the event's return type, not its (always empty) parameters.
type EventListener<F extends Listener> = ReturnType<F> extends void ? () => void : (data: ReturnType<F>) => void;
interface ITypedEventEmitter<Events extends EventsMapping> {
rawEmitter: EventEmitter;

eventNames<Event extends keyof Events>(): Array<Event>;
setMaxListeners(n: number): this;
getMaxListeners(): number;
emit<Event extends keyof Events>(type: Event, ...args: Parameters<Events[Event]>): boolean;
addListener<Event extends keyof Events>(type: Event, listener: Events[Event]): this;
on<Event extends keyof Events>(type: Event, listener: Events[Event]): this;
once<Event extends keyof Events>(type: Event, listener: Events[Event]): this;
prependListener<Event extends keyof Events>(type: Event, listener: Events[Event]): this;
prependOnceListener<Event extends keyof Events>(type: Event, listener: Events[Event]): this;
removeListener<Event extends keyof Events>(type: Event, listener: Events[Event]): this;
off<Event extends keyof Events>(type: Event, listener: Events[Event]): this;
addListener<Event extends keyof Events>(type: Event, listener: EventListener<Events[Event]>): this;
on<Event extends keyof Events>(type: Event, listener: EventListener<Events[Event]>): this;
once<Event extends keyof Events>(type: Event, listener: EventListener<Events[Event]>): this;
prependListener<Event extends keyof Events>(type: Event, listener: EventListener<Events[Event]>): this;
prependOnceListener<Event extends keyof Events>(type: Event, listener: EventListener<Events[Event]>): this;
removeListener<Event extends keyof Events>(type: Event, listener: EventListener<Events[Event]>): this;
off<Event extends keyof Events>(type: Event, listener: EventListener<Events[Event]>): this;
removeAllListeners<Event extends keyof Events>(type?: Event): this;
listeners<Event extends keyof Events>(type: Event): Events[Event][];
listeners<Event extends keyof Events>(type: Event): EventListener<Events[Event]>[];
listenerCount<Event extends keyof Events>(type: Event): number;
rawListeners<Event extends keyof Events>(type: Event): Events[Event][];
rawListeners<Event extends keyof Events>(type: Event): EventListener<Events[Event]>[];
}

export class TypedEventEmitter<Events extends EventsMapping> extends (EventEmitter as unknown as {
Expand Down Expand Up @@ -100,7 +117,7 @@ export interface EventBroadcastMessage<E extends BaseEvent<string, unknown> = Ba
* - [x] setGraphDataset / getGraphDataset / mergeGraphDataset
* - [x] setGraphAppearance / getGraphAppearance / mergeGraphAppearance
* - [x] setFilters / getFilters
* - [ ] setSelection / getSelection
* - [x] setSelection / getSelection
*
* 2. Other methods:
* - [x] ping (to check broadcast status)
Expand All @@ -119,7 +136,7 @@ export interface EventBroadcastMessage<E extends BaseEvent<string, unknown> = Ba
* - [ ] graphModelUpdate
* - [ ] graphAppearanceUpdate
* - [ ] filtersUpdate
* - [ ] selectionUpdate
* - [x] selectionUpdate
*/

/**
Expand All @@ -142,6 +159,9 @@ export type MergeAppearanceMethod = BaseMethod<"mergeAppearance", [Partial<Appea
export type GetFiltersMethod = BaseMethod<"getFilters", [], FiltersState>;
export type SetFiltersMethod = BaseMethod<"setFilters", [FiltersState]>;

export type GetSelectionMethod = BaseMethod<"getSelection", [], SerializedSelectionState>;
export type SetSelectionMethod = BaseMethod<"setSelection", [SerializedSelectionState]>;

export type GephiLiteMethod =
| PingMethod
| GetVersionMethod
Expand All @@ -154,7 +174,9 @@ export type GephiLiteMethod =
| SetAppearanceMethod
| MergeAppearanceMethod
| GetFiltersMethod
| SetFiltersMethod;
| SetFiltersMethod
| GetSelectionMethod
| SetSelectionMethod;

export type GephiLiteMethodBroadcastMessage =
| MethodBroadcastMessage<PingMethod>
Expand All @@ -168,14 +190,17 @@ export type GephiLiteMethodBroadcastMessage =
| MethodBroadcastMessage<SetAppearanceMethod>
| MethodBroadcastMessage<MergeAppearanceMethod>
| MethodBroadcastMessage<GetFiltersMethod>
| MethodBroadcastMessage<SetFiltersMethod>;
| MethodBroadcastMessage<SetFiltersMethod>
| MethodBroadcastMessage<GetSelectionMethod>
| MethodBroadcastMessage<SetSelectionMethod>;

/**
* Event types:
* ************
*/
export type GephiLiteEvents = {
newInstance(): void;
selectionUpdate(): SerializedSelectionState;
};
export type GephiLiteEventData<K extends keyof GephiLiteEvents> = ReturnType<GephiLiteEvents[K]>;
export type GephiLiteEvent<K extends keyof GephiLiteEvents> = BaseEvent<K, GephiLiteEventData<K>>;
84 changes: 84 additions & 0 deletions packages/gephi-lite/src/core/broadcast/client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { GephiLiteDriver } from "@gephi/gephi-lite-broadcast";
import Graph from "graphology";
import { afterEach, describe, expect, it } from "vitest";

// Import order matters here: this package has a pre-existing circular dependency between
// core/graph and core/context/dataContexts (unrelated to this file), and BroadcastClient
// happens to resolve it in the safe direction - importing it before core/graph avoids a
// "Cannot access before initialization" crash on sigmaGraphAtom.
import { BroadcastClient } from "./client";
import { graphDatasetAtom } from "../graph";
import { getEmptyGraphDataset, initializeGraphDataset } from "../graph/utils";
import { selectionActions, selectionAtom } from "../selection";

function buildGraph(nodes: string[]): Graph {
const graph = new Graph();
nodes.forEach((node) => graph.addNode(node));
return graph;
}

describe("BroadcastClient - selection", () => {
let client: BroadcastClient | undefined;
let driver: GephiLiteDriver | undefined;

afterEach(() => {
client?.destroy();
driver?.destroy();
selectionActions.reset();
graphDatasetAtom.set(getEmptyGraphDataset());
});

function setup(nodes: string[] = ["a", "b", "c"]) {
const channelName = `test-selection-${Math.random().toString(36).slice(2)}`;
client = new BroadcastClient(channelName);
driver = new GephiLiteDriver(channelName);
graphDatasetAtom.set(initializeGraphDataset(buildGraph(nodes)));
}

it("getSelection returns the current selection", async () => {
setup();
selectionActions.select({ type: "nodes", items: new Set(["a", "b"]) });

const selection = await driver!.getSelection();

expect(selection).toEqual({ nodeIds: ["a", "b"], edgeIds: [] });
});

it("setSelection updates the selection and emits selectionUpdate", async () => {
setup();
const updates: unknown[] = [];
driver!.on("selectionUpdate", (data) => updates.push(data));

await driver!.setSelection({ nodeIds: ["a", "c"], edgeIds: [] });

expect(Array.from(selectionAtom.get().items).sort()).toEqual(["a", "c"]);
expect(updates).toEqual([{ nodeIds: ["a", "c"], edgeIds: [] }]);
});

it("setGraphDataset keeps still-valid selected ids and drops stale ones", async () => {
setup(["a", "b", "c"]);
selectionActions.select({ type: "nodes", items: new Set(["a", "c"]) });

// The replacement dataset intentionally has a different node COUNT (4, not 3) than the
// first one. This isn't about the selection logic under test - it sidesteps a separate,
// pre-existing bug in filteredGraphAtom (core/graph/index.ts): its derivedAtom() call
// doesn't set `checkOutput: false`, and lodash.isEqual() treats any two different
// graphology Graph instances of equal node/edge count as equal, so a same-size dataset
// swap leaves it silently stale, which then makes the unrelated resetCamera() call inside
// setGraphDataset throw. Reported separately; not fixed here.
await driver!.setGraphDataset(initializeGraphDataset(buildGraph(["b", "c", "d", "e"])));

expect(Array.from(selectionAtom.get().items)).toEqual(["c"]);
});

it("does not emit selectionUpdate when the effective selection does not change", async () => {
setup();
await driver!.setSelection({ nodeIds: ["a"], edgeIds: [] });

const updates: unknown[] = [];
driver!.on("selectionUpdate", (data) => updates.push(data));
await driver!.setSelection({ nodeIds: ["a"], edgeIds: [] });

expect(updates).toEqual([]);
});
});
41 changes: 41 additions & 0 deletions packages/gephi-lite/src/core/broadcast/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
GephiLiteMethodBroadcastMessage,
Message,
MethodReplyMessage,
SerializedSelectionState,
} from "@gephi/gephi-lite-broadcast";
import { AppearanceState, SerializedGraphDataset, deserializeDataset, serializeDataset } from "@gephi/gephi-lite-sdk";
import EventEmitter from "events";
Expand All @@ -23,6 +24,9 @@ import { filtersAtom } from "../filters";
import { FiltersState } from "../filters/types";
import { graphDatasetActions, graphDatasetAtom } from "../graph";
import { dataGraphToFullGraph, initializeGraphDataset } from "../graph/utils";
import { selectionAtom } from "../selection";
import { SelectionState } from "../selection/types";
import { deserializeSelection, pruneSelectionToGraph, selectionStatesAreEqual, serializeSelection } from "../selection/utils";
import { resetCamera } from "../sigma";

/**
Expand Down Expand Up @@ -68,6 +72,12 @@ const BROADCAST_METHODS: {
},
setGraphDataset: async (appearance: SerializedGraphDataset) => {
graphDatasetAtom.set(deserializeDataset(appearance));
// A dataset replacement can drop nodes/edges that were selected under the previous
// dataset. Keep whichever selected ids still exist, drop the rest, and empty the
// selection entirely if none remain -- selectionAtom.set() only notifies listeners
// when the value actually changes (see pruneSelectionToGraph), so this is a no-op
// when the selection was already empty or entirely unaffected.
selectionAtom.set(pruneSelectionToGraph(selectionAtom.get(), graphDatasetAtom.get().fullGraph));
resetCamera({ forceRefresh: true });
},
mergeGraphDataset: async (appearance: Partial<SerializedGraphDataset>) => {
Expand All @@ -90,6 +100,16 @@ const BROADCAST_METHODS: {
setFilters: async (filters: FiltersState) => {
filtersAtom.set(filters);
},

getSelection: async () => {
return serializeSelection(selectionAtom.get());
},
setSelection: async (selection: SerializedSelectionState) => {
const requested = deserializeSelection(selection, selectionAtom.get().graphSelectionMode);
// Unknown ids (not present in the current graph) must never pollute the internal
// state -- same pruning helper used after a dataset replacement.
selectionAtom.set(pruneSelectionToGraph(requested, graphDatasetAtom.get().fullGraph));
},
};

/**
Expand All @@ -98,6 +118,14 @@ const BROADCAST_METHODS: {
*/
export class BroadcastClient extends EventEmitter {
private channel: BroadcastChannel;
// Tracks the last selection actually broadcast, so a selectionUpdate is only ever sent
// when the *effective* selection changes -- atoms only notify listeners based on
// reference equality (see @ouestware/atoms' atom()), not value equality, and every
// select/toggle/setSelection call constructs a fresh SelectionState object regardless of
// whether its content differs from before. This also means a setSelection() call that
// requests the selection Gephi Lite already has produces no outgoing event, so external
// callers echoing back a selection they just received will not create a broadcast loop.
private lastBroadcastSelection: SerializedSelectionState;

constructor(name: string) {
super();
Expand All @@ -121,8 +149,19 @@ export class BroadcastClient extends EventEmitter {
this.channel.postMessage(replyMessage);
}
};

this.lastBroadcastSelection = serializeSelection(selectionAtom.get());
selectionAtom.bind(this.handleSelectionChange);
}

private handleSelectionChange = (state: SelectionState) => {
const serialized = serializeSelection(state);
if (selectionStatesAreEqual(serialized, this.lastBroadcastSelection)) return;

this.lastBroadcastSelection = serialized;
this.broadcastEvent("selectionUpdate", serialized);
};

private callMethod<Method extends GephiLiteMethod>(
method: Method["method"],
args: Method["arguments"],
Expand All @@ -143,6 +182,8 @@ export class BroadcastClient extends EventEmitter {
}

destroy(): void {
selectionAtom.unbind(this.handleSelectionChange);

this.channel.onmessage = null;
this.channel.close();

Expand Down
1 change: 1 addition & 0 deletions packages/gephi-lite/src/core/broadcast/useBroadcast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function useBroadcast(broadcastID?: string | null) {

const _handlers: Omit<GephiLiteEvents, "newInstance"> = {
// TODO
selectionUpdate: () => ({ nodeIds: [], edgeIds: [] }),
};
// TODO: Bind handlers

Expand Down
Loading