diff --git a/packages/broadcast/src/driver.ts b/packages/broadcast/src/driver.ts index ba654be5..dbce0a9a 100644 --- a/packages/broadcast/src/driver.ts +++ b/packages/broadcast/src/driver.ts @@ -18,6 +18,7 @@ import { GetFiltersMethod, GetGraphDatasetMethod, GetGraphMethod, + GetSelectionMethod, GetVersionMethod, ImportGraphMethod, MergeAppearanceMethod, @@ -26,9 +27,11 @@ import { MethodBroadcastMessage, PingMethod, ReplyMessage, + SerializedSelectionState, SetAppearanceMethod, SetFiltersMethod, SetGraphDatasetMethod, + SetSelectionMethod, TypedEventEmitter, } from "./types"; @@ -142,6 +145,12 @@ export class GephiLiteDriver extends TypedEventEmitter { setFilters(filters: FiltersState) { return this.callMethod("setFilters", filters); } + getSelection() { + return this.callMethod("getSelection"); + } + setSelection(selection: SerializedSelectionState) { + return this.callMethod("setSelection", selection); + } getWindow() { return this.window; } diff --git a/packages/broadcast/src/types.ts b/packages/broadcast/src/types.ts index e9f58f06..9bcef973 100644 --- a/packages/broadcast/src/types.ts +++ b/packages/broadcast/src/types.ts @@ -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: * ************* @@ -10,6 +23,10 @@ import { SerializedGraph } from "graphology-types"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type Listener = (...args: any[]) => void; type EventsMapping = Record; +// 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 = ReturnType extends void ? () => void : (data: ReturnType) => void; interface ITypedEventEmitter { rawEmitter: EventEmitter; @@ -17,17 +34,17 @@ interface ITypedEventEmitter { setMaxListeners(n: number): this; getMaxListeners(): number; emit(type: Event, ...args: Parameters): boolean; - addListener(type: Event, listener: Events[Event]): this; - on(type: Event, listener: Events[Event]): this; - once(type: Event, listener: Events[Event]): this; - prependListener(type: Event, listener: Events[Event]): this; - prependOnceListener(type: Event, listener: Events[Event]): this; - removeListener(type: Event, listener: Events[Event]): this; - off(type: Event, listener: Events[Event]): this; + addListener(type: Event, listener: EventListener): this; + on(type: Event, listener: EventListener): this; + once(type: Event, listener: EventListener): this; + prependListener(type: Event, listener: EventListener): this; + prependOnceListener(type: Event, listener: EventListener): this; + removeListener(type: Event, listener: EventListener): this; + off(type: Event, listener: EventListener): this; removeAllListeners(type?: Event): this; - listeners(type: Event): Events[Event][]; + listeners(type: Event): EventListener[]; listenerCount(type: Event): number; - rawListeners(type: Event): Events[Event][]; + rawListeners(type: Event): EventListener[]; } export class TypedEventEmitter extends (EventEmitter as unknown as { @@ -100,7 +117,7 @@ export interface EventBroadcastMessage = 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) @@ -119,7 +136,7 @@ export interface EventBroadcastMessage = Ba * - [ ] graphModelUpdate * - [ ] graphAppearanceUpdate * - [ ] filtersUpdate - * - [ ] selectionUpdate + * - [x] selectionUpdate */ /** @@ -142,6 +159,9 @@ export type MergeAppearanceMethod = BaseMethod<"mergeAppearance", [Partial; export type SetFiltersMethod = BaseMethod<"setFilters", [FiltersState]>; +export type GetSelectionMethod = BaseMethod<"getSelection", [], SerializedSelectionState>; +export type SetSelectionMethod = BaseMethod<"setSelection", [SerializedSelectionState]>; + export type GephiLiteMethod = | PingMethod | GetVersionMethod @@ -154,7 +174,9 @@ export type GephiLiteMethod = | SetAppearanceMethod | MergeAppearanceMethod | GetFiltersMethod - | SetFiltersMethod; + | SetFiltersMethod + | GetSelectionMethod + | SetSelectionMethod; export type GephiLiteMethodBroadcastMessage = | MethodBroadcastMessage @@ -168,7 +190,9 @@ export type GephiLiteMethodBroadcastMessage = | MethodBroadcastMessage | MethodBroadcastMessage | MethodBroadcastMessage - | MethodBroadcastMessage; + | MethodBroadcastMessage + | MethodBroadcastMessage + | MethodBroadcastMessage; /** * Event types: @@ -176,6 +200,7 @@ export type GephiLiteMethodBroadcastMessage = */ export type GephiLiteEvents = { newInstance(): void; + selectionUpdate(): SerializedSelectionState; }; export type GephiLiteEventData = ReturnType; export type GephiLiteEvent = BaseEvent>; diff --git a/packages/gephi-lite/src/core/broadcast/client.spec.ts b/packages/gephi-lite/src/core/broadcast/client.spec.ts new file mode 100644 index 00000000..ef5d1154 --- /dev/null +++ b/packages/gephi-lite/src/core/broadcast/client.spec.ts @@ -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([]); + }); +}); diff --git a/packages/gephi-lite/src/core/broadcast/client.ts b/packages/gephi-lite/src/core/broadcast/client.ts index a5f1c95f..8940da2c 100644 --- a/packages/gephi-lite/src/core/broadcast/client.ts +++ b/packages/gephi-lite/src/core/broadcast/client.ts @@ -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"; @@ -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"; /** @@ -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) => { @@ -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)); + }, }; /** @@ -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(); @@ -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: Method["method"], args: Method["arguments"], @@ -143,6 +182,8 @@ export class BroadcastClient extends EventEmitter { } destroy(): void { + selectionAtom.unbind(this.handleSelectionChange); + this.channel.onmessage = null; this.channel.close(); diff --git a/packages/gephi-lite/src/core/broadcast/useBroadcast.tsx b/packages/gephi-lite/src/core/broadcast/useBroadcast.tsx index d6c7ac6f..e6d72f0a 100644 --- a/packages/gephi-lite/src/core/broadcast/useBroadcast.tsx +++ b/packages/gephi-lite/src/core/broadcast/useBroadcast.tsx @@ -32,6 +32,7 @@ export function useBroadcast(broadcastID?: string | null) { const _handlers: Omit = { // TODO + selectionUpdate: () => ({ nodeIds: [], edgeIds: [] }), }; // TODO: Bind handlers diff --git a/packages/gephi-lite/src/core/selection/utils.ts b/packages/gephi-lite/src/core/selection/utils.ts index 520d4395..716bc97f 100644 --- a/packages/gephi-lite/src/core/selection/utils.ts +++ b/packages/gephi-lite/src/core/selection/utils.ts @@ -1,4 +1,7 @@ -import { DEFAULT_GRAPH_SELECTION_MODE, SelectionState } from "./types"; +import { SerializedSelectionState } from "@gephi/gephi-lite-broadcast"; +import { DatalessGraph } from "@gephi/gephi-lite-sdk"; + +import { DEFAULT_GRAPH_SELECTION_MODE, GraphSelectionMode, SelectionState } from "./types"; /** * Returns an empty selection state: @@ -10,3 +13,73 @@ export function getEmptySelectionState(): SelectionState { graphSelectionMode: DEFAULT_GRAPH_SELECTION_MODE, }; } + +/** + * Broadcast API helpers: + * ********************** + * Gephi Lite's internal selection model only ever has one active item type + * (nodes XOR edges) - these helpers translate to/from the broadcast-facing + * SerializedSelectionState shape ({ nodeIds, edgeIds }), which does not leak + * that implementation detail to external callers. + */ + +/** + * Converts the internal selection state to its broadcast-facing shape. + */ +export function serializeSelection(selection: SelectionState): SerializedSelectionState { + const ids = Array.from(selection.items); + return { + nodeIds: selection.type === "nodes" ? ids : [], + edgeIds: selection.type === "edges" ? ids : [], + }; +} + +/** + * Converts a broadcast-facing selection back to the internal shape. Gephi Lite cannot + * represent a simultaneous nodes+edges selection today, so if both nodeIds and edgeIds + * are provided, nodeIds take precedence (documented limitation, not silently resolved). + * `graphSelectionMode` is a UI-only concept unrelated to *what* is selected, so the + * caller's current mode is preserved rather than reset. + */ +export function deserializeSelection( + serialized: SerializedSelectionState, + graphSelectionMode: GraphSelectionMode, +): SelectionState { + if (serialized.nodeIds.length > 0) { + return { type: "nodes", items: new Set(serialized.nodeIds), graphSelectionMode }; + } + if (serialized.edgeIds.length > 0) { + return { type: "edges", items: new Set(serialized.edgeIds), graphSelectionMode }; + } + return { type: "nodes", items: new Set(), graphSelectionMode }; +} + +/** + * Order-independent equality check on the broadcast-facing shape - used to decide whether + * a selectionUpdate event is actually needed, since atoms only compare by reference (see + * @ouestware/atoms), not by value. + */ +export function selectionStatesAreEqual( + a: SerializedSelectionState, + b: SerializedSelectionState | null, +): boolean { + if (!b) return false; + const sameIds = (x: string[], y: string[]) => x.length === y.length && new Set(x).size === new Set(y).size && x.every((id) => y.includes(id)); + return sameIds(a.nodeIds, b.nodeIds) && sameIds(a.edgeIds, b.edgeIds); +} + +/** + * Drops any selected id that no longer exists in the given graph. Used (1) after a + * dataset replacement via the broadcast API, so a stale selection never lingers, and (2) + * when an external caller sets a selection directly, so unknown ids never pollute the + * internal state. + */ +export function pruneSelectionToGraph(selection: SelectionState, graph: DatalessGraph): SelectionState { + if (selection.items.size === 0) return selection; + + const exists = selection.type === "nodes" ? (id: string) => graph.hasNode(id) : (id: string) => graph.hasEdge(id); + const validItems = new Set(Array.from(selection.items).filter(exists)); + if (validItems.size === selection.items.size) return selection; + + return { ...selection, items: validItems }; +} diff --git a/packages/gephi-lite/vitest.config.mts b/packages/gephi-lite/vitest.config.mts index 2292a674..428190eb 100644 --- a/packages/gephi-lite/vitest.config.mts +++ b/packages/gephi-lite/vitest.config.mts @@ -1,6 +1,8 @@ +import UnpluginTypia from "@ryoppippi/unplugin-typia/vite"; import { defineConfig } from "vitest/config"; export default defineConfig({ + plugins: [UnpluginTypia({})], test: { globals: true, browser: {