Skip to content

Commit 7e85871

Browse files
committed
[gephi-lite] Improves geolayout and layout buttons
Details: - Improves LayoutButton types and logic - Replaces centerMissing boolean with missingStrategy - Adds inferSettings and hideReset options to layout types - Infers geo layout settings on open panel - Adds a button to run geo layout AND open the map layer, with the related panel opened
1 parent 9892453 commit 7e85871

File tree

9 files changed

+272
-64
lines changed

9 files changed

+272
-64
lines changed

packages/gephi-lite/src/components/common-icons.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
PiMagnifyingGlass,
5454
PiMagnifyingGlassMinus,
5555
PiMagnifyingGlassPlus,
56+
PiMapTrifold,
5657
PiMoonStars,
5758
PiMoonStarsFill,
5859
PiPaintBrush,
@@ -140,6 +141,7 @@ export const LightThemeIcon = PiSun;
140141
export const LightThemeSelectedIcon = PiSunFill;
141142
export const LockIcon = PiLock;
142143
export const LoginIcon = PiSignIn;
144+
export const MapIcon = PiMapTrifold;
143145
export const MarqueeIcon = PiSelection;
144146
export const MarqueeIconFill = PiSelectionBold;
145147
export const MenuCollapseIcon = PiCaretDown;

packages/gephi-lite/src/core/context/eventsContext.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export const EVENTS = {
1111
nodeCreated: "nodeCreated",
1212
edgeCreated: "edgeCreated",
1313
searchResultsSelected: "searchResultsSelected",
14+
// Navigation:
15+
openPanel: "openPanel",
1416
} as const;
1517

1618
/**

packages/gephi-lite/src/core/layouts/collection/forceAtlas2.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ForceAtlas2LayoutParameters, ForceAtlas2Settings, inferSettings } from
55
import RAW_FA2_DEFAULT_SETTINGS from "graphology-layout-forceatlas2/defaults";
66
import FA2Layout from "graphology-layout-forceatlas2/worker";
77

8+
import { GuessSettingsIcon } from "../../../components/common-icons";
89
import { WorkerLayout } from "../types";
910

1011
const FA2_DEFAULT_SETTINGS = RAW_FA2_DEFAULT_SETTINGS as Required<ForceAtlas2Settings>;
@@ -16,9 +17,12 @@ export const ForceAtlas2Layout = {
1617
buttons: [
1718
{
1819
id: "autoSettings",
20+
icon: GuessSettingsIcon,
1921
description: true,
20-
getSettings(_currentSettings, dataGraph: DataGraph) {
21-
return { ...FA2_DEFAULT_SETTINGS, ...inferSettings(dataGraph) };
22+
onClick(_currentSettings, dataGraph: DataGraph) {
23+
return {
24+
setSettings: { ...FA2_DEFAULT_SETTINGS, ...inferSettings(dataGraph) },
25+
};
2226
},
2327
},
2428
],
Lines changed: 131 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,140 @@
1+
import { DataGraph } from "@gephi/gephi-lite-sdk";
2+
3+
import { MapIcon } from "../../../components/common-icons";
4+
import { appearanceActions } from "../../appearance";
5+
import { EVENTS, emitter } from "../../context/eventsContext";
16
import { LayoutMapping, SyncLayout } from "../types";
27

8+
const LAT_RE = /^(lat|latitude|y_?coord)$/i;
9+
const LNG_RE = /^(lng|lon|long|longitude|x_?coord)$/i;
10+
11+
export type MissingStrategy = "keep" | "grid" | "barycentergrid";
12+
313
export interface GeographicLayoutSettings {
414
latitudeField?: string;
515
longitudeField?: string;
6-
centerMissing: boolean;
16+
missingStrategy: MissingStrategy;
17+
}
18+
19+
function computeGridPositions(
20+
nodeIds: string[],
21+
extent: { minX: number; maxX: number; minY: number; maxY: number },
22+
): LayoutMapping {
23+
const result: LayoutMapping = {};
24+
if (nodeIds.length === 0) return result;
25+
26+
const width = extent.maxX - extent.minX || 1;
27+
const height = extent.maxY - extent.minY || 1;
28+
const cols = Math.ceil(Math.sqrt(nodeIds.length));
29+
const spacing = Math.min(width, height) / Math.max(cols, 1);
30+
const offsetX = extent.minX - width * 0.2 - cols * spacing;
31+
const startY = (extent.minY + extent.maxY) / 2 - (Math.ceil(nodeIds.length / cols) * spacing) / 2;
32+
33+
nodeIds.forEach((nodeId, i) => {
34+
const col = i % cols;
35+
const row = Math.floor(i / cols);
36+
result[nodeId] = { x: offsetX + col * spacing, y: startY + row * spacing };
37+
});
38+
return result;
39+
}
40+
41+
function runGeographic(graph: DataGraph, options?: { settings: GeographicLayoutSettings }): LayoutMapping {
42+
const { latitudeField, longitudeField, missingStrategy = "keep" } = options?.settings || {};
43+
const result: LayoutMapping = {};
44+
45+
if (!latitudeField || !longitudeField) return result;
46+
47+
const validIds: string[] = [];
48+
const missingIds: string[] = [];
49+
50+
graph.forEachNode((nodeId, attrs) => {
51+
const lat = attrs[latitudeField];
52+
const lng = attrs[longitudeField];
53+
if (typeof lat === "number" && typeof lng === "number" && !isNaN(lat) && !isNaN(lng)) {
54+
result[nodeId] = { x: lng, y: lat };
55+
validIds.push(nodeId);
56+
} else {
57+
missingIds.push(nodeId);
58+
}
59+
});
60+
61+
if (missingStrategy === "keep" || missingIds.length === 0 || validIds.length === 0) return result;
62+
63+
// Compute extent of geolocated nodes
64+
let minX = Infinity,
65+
maxX = -Infinity,
66+
minY = Infinity,
67+
maxY = -Infinity;
68+
for (const id of validIds) {
69+
const { x, y } = result[id];
70+
if (x < minX) minX = x;
71+
if (x > maxX) maxX = x;
72+
if (y < minY) minY = y;
73+
if (y > maxY) maxY = y;
74+
}
75+
const extent = { minX, maxX, minY, maxY };
76+
77+
if (missingStrategy === "grid") {
78+
Object.assign(result, computeGridPositions(missingIds, extent));
79+
} else if (missingStrategy === "barycentergrid") {
80+
const validSet = new Set(validIds);
81+
const gridIds: string[] = [];
82+
83+
for (const nodeId of missingIds) {
84+
const geoNeighbors = graph.neighbors(nodeId).filter((n) => validSet.has(n));
85+
if (geoNeighbors.length > 0) {
86+
let sx = 0,
87+
sy = 0;
88+
for (const n of geoNeighbors) {
89+
sx += result[n].x;
90+
sy += result[n].y;
91+
}
92+
result[nodeId] = { x: sx / geoNeighbors.length, y: sy / geoNeighbors.length };
93+
} else {
94+
gridIds.push(nodeId);
95+
}
96+
}
97+
98+
Object.assign(result, computeGridPositions(gridIds, extent));
99+
}
100+
101+
return result;
102+
}
103+
104+
function inferGeographicSettings(dataGraph: DataGraph): Partial<GeographicLayoutSettings> {
105+
if (dataGraph.order === 0) return {};
106+
const sample = dataGraph.getNodeAttributes(dataGraph.nodes()[0]);
107+
const numericKeys = Object.keys(sample).filter((k) => typeof sample[k] === "number");
108+
return {
109+
latitudeField: numericKeys.find((k) => LAT_RE.test(k)),
110+
longitudeField: numericKeys.find((k) => LNG_RE.test(k)),
111+
};
7112
}
8113

9114
export const GeographicLayout = {
10115
id: "geographic",
11116
type: "sync",
12117
description: true,
118+
hideReset: true,
119+
inferSettings: inferGeographicSettings,
120+
buttons: [
121+
{
122+
id: "applyWithMap",
123+
description: true,
124+
icon: MapIcon,
125+
onClick() {
126+
return {
127+
applyLayout: true,
128+
before() {
129+
appearanceActions.setBackgroundLayer({ type: "map", map: { engine: "maplibre" } });
130+
},
131+
then() {
132+
emitter.emit(EVENTS.openPanel, "appearance-background");
133+
},
134+
};
135+
},
136+
},
137+
],
13138
parameters: [
14139
{
15140
id: "latitudeField",
@@ -28,34 +153,12 @@ export const GeographicLayout = {
28153
description: true,
29154
},
30155
{
31-
id: "centerMissing",
32-
type: "boolean",
33-
defaultValue: false,
156+
id: "missingStrategy",
157+
type: "enum",
158+
options: [{ id: "keep" }, { id: "grid" }, { id: "barycentergrid" }],
159+
defaultValue: "keep",
34160
description: true,
35161
},
36162
],
37-
run(graph, options) {
38-
const { latitudeField, longitudeField, centerMissing = false } = options?.settings || {};
39-
const result: LayoutMapping = {};
40-
41-
if (!latitudeField || !longitudeField) {
42-
return result;
43-
}
44-
45-
graph.forEachNode((nodeId, attrs) => {
46-
const lat = attrs[latitudeField];
47-
const lng = attrs[longitudeField];
48-
49-
if (typeof lat === "number" && typeof lng === "number" && !isNaN(lat) && !isNaN(lng)) {
50-
// Valid coordinates: x = longitude, y = latitude
51-
result[nodeId] = { x: lng, y: lat };
52-
} else if (centerMissing) {
53-
// Missing values: place at center (0, 0)
54-
result[nodeId] = { x: 0, y: 0 };
55-
}
56-
// If !centerMissing, we don't add to result (node keeps current position)
57-
});
58-
59-
return result;
60-
},
163+
run: runGeographic,
61164
} as SyncLayout<GeographicLayoutSettings>;

packages/gephi-lite/src/core/layouts/types.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { FieldModelType } from "@gephi/gephi-lite-sdk";
22
import Graph from "graphology";
33
import { ConnectedClosenessResult } from "graphology-metrics/layout-quality/connected-closeness";
4+
import { ComponentType } from "react";
45
import { Coordinates } from "sigma/types";
56

67
import { DataGraph, ItemData } from "../graph/types";
@@ -37,6 +38,12 @@ export interface LayoutAttributeParameter extends BaseLayoutParameter {
3738
restriction?: FieldModelType[];
3839
}
3940

41+
export interface LayoutEnumParameter extends BaseLayoutParameter {
42+
type: "enum";
43+
options: Array<{ id: string }>;
44+
defaultValue: string;
45+
}
46+
4047
export type LayoutScriptFunction = (
4148
id: string,
4249
attributes: ItemData,
@@ -54,12 +61,21 @@ export type LayoutParameter =
5461
| LayoutScriptParameter
5562
| LayoutBooleanParameter
5663
| LayoutNumberParameter
57-
| LayoutAttributeParameter;
64+
| LayoutAttributeParameter
65+
| LayoutEnumParameter;
66+
67+
export interface LayoutButtonInstructions<P = unknown> {
68+
setSettings?: P;
69+
applyLayout?: boolean;
70+
before?: () => void;
71+
then?: () => void;
72+
}
5873

5974
export interface LayoutButton<P = unknown> {
6075
id: string;
6176
description?: boolean;
62-
getSettings: (currentSettings: P, dataGraph: DataGraph) => P;
77+
icon?: ComponentType;
78+
onClick: (currentSettings: P, dataGraph: DataGraph) => LayoutButtonInstructions<P>;
6379
}
6480

6581
/**
@@ -73,8 +89,10 @@ export interface SyncLayout<P = any> {
7389
id: string;
7490
type: "sync";
7591
description?: boolean;
92+
hideReset?: boolean;
7693
buttons?: Array<LayoutButton<P>>;
7794
parameters: Array<LayoutParameter>;
95+
inferSettings?: (dataGraph: DataGraph) => Partial<P>;
7896
run: (graph: DataGraph, options?: { settings: P }) => LayoutMapping;
7997
}
8098

@@ -93,8 +111,10 @@ export interface WorkerLayout<P = any> {
93111
id: string;
94112
type: "worker";
95113
description?: boolean;
114+
hideReset?: boolean;
96115
buttons?: Array<LayoutButton<P>>;
97116
parameters: Array<LayoutParameter>;
117+
inferSettings?: (dataGraph: DataGraph) => Partial<P>;
98118
supervisor: WorkerSupervisorConstructor;
99119
}
100120

packages/gephi-lite/src/locales/en.json

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,12 @@
429429
"description": "This panel allows computing new coordinates to nodes of the graph. ",
430430
"geographic": {
431431
"description": "Positions nodes according to their geographic coordinates (latitude/longitude)",
432+
"buttons": {
433+
"applyWithMap": {
434+
"description": "Apply layout and enable map background",
435+
"title": "Apply with map"
436+
}
437+
},
432438
"parameters": {
433439
"latitudeField": {
434440
"description": "Node attribute containing latitude values",
@@ -438,9 +444,14 @@
438444
"description": "Node attribute containing longitude values",
439445
"title": "Longitude field"
440446
},
441-
"centerMissing": {
442-
"description": "Place nodes with missing coordinates at the center (0,0). If unchecked, they keep their current position.",
443-
"title": "Center missing nodes"
447+
"missingStrategy": {
448+
"description": "How to handle nodes without valid geographic coordinates",
449+
"title": "Missing coordinates",
450+
"options": {
451+
"keep": "Keep current position",
452+
"grid": "Place in grid (left of map)",
453+
"barycentergrid": "Neighbors barycenter + grid"
454+
}
444455
}
445456
},
446457
"title": "Geographic"

packages/gephi-lite/src/views/graphPage/index.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import cx from "classnames";
2-
import { type ComponentType, FC, useEffect, useState } from "react";
2+
import { type ComponentType, FC, useCallback, useEffect, useState } from "react";
33
import { useTranslation } from "react-i18next";
44
import { PiX } from "react-icons/pi";
55

@@ -25,6 +25,7 @@ import {
2525
} from "../../components/common-icons";
2626
import { LayoutQualityForm } from "../../components/forms/LayoutQualityForm";
2727
import { useSelection, useSelectionActions } from "../../core/context/dataContexts";
28+
import { EVENTS, emitter } from "../../core/context/eventsContext";
2829
import { LAYOUTS } from "../../core/layouts/collection";
2930
import { EDGE_METRICS, MIXED_METRICS, NODE_METRICS } from "../../core/metrics/collections";
3031
import { useMobile } from "../../hooks/useMobile";
@@ -36,7 +37,19 @@ import { LabelsPanel } from "./panels/LabelsPanel";
3637
import { MetricsPanel } from "./panels/MetricsPanel";
3738
import { LayoutPanel } from "./panels/layouts/LayoutPanel";
3839

39-
const MENU: MenuItem<{ panel?: ComponentType }>[] = [
40+
type PanelMenuItem = MenuItem<{ panel?: ComponentType }>;
41+
42+
function findMenuPanel(menu: PanelMenuItem[], id: string): { id: string; panel: ComponentType } | undefined {
43+
for (const item of menu) {
44+
if (item.id === id && "panel" in item && item.panel) return { id: item.id, panel: item.panel };
45+
if ("children" in item) {
46+
const found = findMenuPanel(item.children, id);
47+
if (found) return found;
48+
}
49+
}
50+
}
51+
52+
const MENU: PanelMenuItem[] = [
4053
{
4154
id: "layout",
4255
i18nKey: "layouts.title",
@@ -118,6 +131,21 @@ export const GraphPage: FC = () => {
118131
// Mobile display:
119132
const [expanded, setExpanded] = useState(false);
120133

134+
const openPanel = useCallback(
135+
(panelId: string) => {
136+
const found = findMenuPanel(MENU, panelId);
137+
if (found) setSelectedTool(found);
138+
},
139+
[setSelectedTool],
140+
);
141+
142+
useEffect(() => {
143+
emitter.on(EVENTS.openPanel, openPanel);
144+
return () => {
145+
emitter.off(EVENTS.openPanel, openPanel);
146+
};
147+
}, [openPanel]);
148+
121149
const selectionPanel = (
122150
<div className={cx("panel panel-right panel-expandable panel-selection", items.size > 0 && "deployed")}>
123151
<button

0 commit comments

Comments
 (0)