Skip to content

Commit 6d02f94

Browse files
committed
[gephi-lite] Moves geo projections logic
Details: - Moves geo projection logic from the visual getters to the layout - Removes MapCoordinateMapping type and all coordinateMapping plumbing - Generalizes smoothClamp/smoothClampInverse with explicit margin parameter - Replaces MercatorCoordinate.fromLngLat with linear mapping + smoothClamp(y, 180, 90) - Adds various projections from d3-geo (new dependency) - Disables "Apply with map" button when projection is not Web Mercator
1 parent 25593be commit 6d02f94

File tree

17 files changed

+249
-501
lines changed

17 files changed

+249
-501
lines changed

package-lock.json

Lines changed: 45 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/gephi-lite/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"byte-size": "^9.0.1",
5050
"chroma-js": "^3.1.2",
5151
"classnames": "^2.5.1",
52+
"d3-geo": "^3.1.1",
5253
"color": "^4.2.3",
5354
"copy-to-clipboard": "^3.3.3",
5455
"file-saver": "^2.0.5",
@@ -106,6 +107,7 @@
106107
"@types/byte-size": "^8.1.2",
107108
"@types/chroma-js": "^3.1.0",
108109
"@types/color": "^4.2.0",
110+
"@types/d3-geo": "^3.1.0",
109111
"@types/file-saver": "^2.0.7",
110112
"@types/is-url": "^1.2.32",
111113
"@types/linkify-it": "^5.0.0",

packages/gephi-lite/src/components/GraphAppearance/index.tsx

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import { nodeExtent } from "graphology-metrics/graph";
21
import { FC, useCallback, useMemo } from "react";
32
import { useTranslation } from "react-i18next";
43

5-
import { useAppearance, useAppearanceActions, useSigmaGraph } from "../../core/context/dataContexts";
4+
import { useAppearance, useAppearanceActions } from "../../core/context/dataContexts";
65
import { ItemType } from "../../core/types";
7-
import { computeCoordinateMappingFromGraph } from "../../utils/geo";
86
import ColorPicker from "../ColorPicker";
97
import { EnumInput } from "../forms/TypedInputs";
108
import { MapBackgroundLayerForm } from "./background/MapBackgroundLayerForm";
@@ -60,7 +58,6 @@ export const GraphBackgroundAppearance: FC<unknown> = () => {
6058
const { t } = useTranslation();
6159
const { backgroundColor, layoutGridColor, backgroundLayer } = useAppearance();
6260
const { setBackgroundColorAppearance, setLayoutGridColorAppearance, setBackgroundLayer } = useAppearanceActions();
63-
const sigmaGraph = useSigmaGraph();
6461

6562
const layerMode: LayerMode = backgroundLayer?.type || "none";
6663

@@ -73,37 +70,8 @@ export const GraphBackgroundAppearance: FC<unknown> = () => {
7370
);
7471

7572
const enableMapLayer = useCallback(() => {
76-
if (sigmaGraph.order === 0) {
77-
// Empty graph: use a default mapping centered at 0,0
78-
setBackgroundLayer({
79-
type: "map",
80-
map: {
81-
engine: "maplibre",
82-
coordinateMapping: {
83-
graphExtent: { minX: -1, maxX: 1, minY: -1, maxY: 1 },
84-
geoExtent: { west: -20, east: 20, south: -20, north: 20 },
85-
},
86-
},
87-
});
88-
return;
89-
}
90-
91-
const { x, y } = nodeExtent(sigmaGraph, ["x", "y"]);
92-
const graphExtent = { minX: x[0], maxX: x[1], minY: y[0], maxY: y[1] };
93-
94-
// Auto-detect if positions look geographic (after Geographic layout)
95-
const looksGeographic =
96-
graphExtent.minX >= -180 && graphExtent.maxX <= 180 && graphExtent.minY >= -90 && graphExtent.maxY <= 90;
97-
98-
if (looksGeographic) {
99-
// Use direct mapping (x=lng, y=lat)
100-
setBackgroundLayer({ type: "map", map: { engine: "maplibre" } });
101-
} else {
102-
// Compute coordinate mapping for arbitrary positions
103-
const coordinateMapping = computeCoordinateMappingFromGraph(graphExtent);
104-
setBackgroundLayer({ type: "map", map: { engine: "maplibre", coordinateMapping } });
105-
}
106-
}, [sigmaGraph, setBackgroundLayer]);
73+
setBackgroundLayer({ type: "map", map: { engine: "maplibre" } });
74+
}, [setBackgroundLayer]);
10775

10876
return (
10977
<div className="panel-body">

packages/gephi-lite/src/core/appearance/utils.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@ import {
1212
import chroma from "chroma-js";
1313
import { Attributes } from "graphology-types";
1414
import { forEach, identity, isNil, keyBy } from "lodash";
15-
import { MercatorCoordinate } from "maplibre-gl";
1615
import { EdgeLabelDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering";
1716
import { EdgeDisplayData, NodeDisplayData } from "sigma/types";
1817

19-
import { computeMercatorSizeRatio, geoToGraph, graphToGeo } from "../../utils/geo";
18+
import { MAP_Y_LIMIT, MAP_Y_MARGIN, MERCATOR_SIZE_RATIO, smoothClamp, smoothClampInverse } from "../../utils/geo";
2019
import { mergeStaticDynamicData } from "../graph/dynamicAttributes";
2120
import { getFieldValue, getFieldValueForQuantification } from "../graph/fieldModel";
2221
import {
@@ -307,34 +306,32 @@ export function getAllVisualGetters(
307306
appearance: AppearanceState,
308307
isClamped: boolean,
309308
): VisualGetters {
310-
// Check if map mode is active
311-
const mapConfig = appearance.backgroundLayer?.type === "map" ? appearance.backgroundLayer.map : null;
312-
const coordinateMapping = mapConfig?.coordinateMapping;
313-
const sizeRatio = mapConfig ? computeMercatorSizeRatio(coordinateMapping) : 1;
309+
const isMap = appearance.backgroundLayer?.type === "map";
314310

315311
// Base size getters
316312
const baseGetNodeSize = makeGetNumberAttr("nodes", "size", dataset, dynamicNodeData, appearance);
317313
const baseGetEdgeSize = makeGetNumberAttr("edges", "size", dataset, dynamicNodeData, appearance);
318314

319315
// Wrap size getters to apply sizeRatio in map mode
320316
const getNodeSize: NumberGetter | null =
321-
baseGetNodeSize && sizeRatio !== 1 ? (data) => baseGetNodeSize(data) * sizeRatio : baseGetNodeSize;
317+
baseGetNodeSize && isMap ? (data) => baseGetNodeSize(data) * MERCATOR_SIZE_RATIO : baseGetNodeSize;
322318
const getEdgeSize: NumberGetter | null =
323-
baseGetEdgeSize && sizeRatio !== 1 ? (data) => baseGetEdgeSize(data) * sizeRatio : baseGetEdgeSize;
319+
baseGetEdgeSize && isMap ? (data) => baseGetEdgeSize(data) * MERCATOR_SIZE_RATIO : baseGetEdgeSize;
324320

325-
const getNodePosition: CoordinateGetter | null = mapConfig
321+
const getNodePosition: CoordinateGetter | null = isMap
326322
? (pos) => {
327-
const geo = graphToGeo(pos.x, pos.y, coordinateMapping, isClamped);
328-
const mercator = MercatorCoordinate.fromLngLat({ lng: geo.lng, lat: geo.lat });
329-
return { x: mercator.x, y: 1 - mercator.y };
323+
const y = isClamped ? smoothClamp(pos.y, MAP_Y_LIMIT, MAP_Y_MARGIN) : pos.y;
324+
return { x: (pos.x + 180) / 360, y: (180 + y) / 360 };
330325
}
331326
: null;
332327

333-
const reverseNodePosition: CoordinateGetter | null = mapConfig
328+
const reverseNodePosition: CoordinateGetter | null = isMap
334329
? (pos) => {
335-
const mercator = new MercatorCoordinate(pos.x, 1 - pos.y, 0);
336-
const lngLat = mercator.toLngLat();
337-
return geoToGraph(lngLat.lat, lngLat.lng, coordinateMapping, isClamped);
330+
const rawY = pos.y * 360 - 180;
331+
return {
332+
x: pos.x * 360 - 180,
333+
y: isClamped ? smoothClampInverse(rawY, MAP_Y_LIMIT, MAP_Y_MARGIN) : rawY,
334+
};
338335
}
339336
: null;
340337

packages/gephi-lite/src/core/graph/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -464,12 +464,12 @@ export const dynamicItemDataAtom = derivedAtom(
464464
// Updated reactively (via bindEffect below) and imperatively by the layout supervisor.
465465
export const mapClampedAtom = atom(false);
466466
const mapClampInputsAtom = derivedAtom([graphDatasetAtom, appearanceAtom], (dataset, appearance) => {
467-
const mapConfig = appearance.backgroundLayer?.type === "map" ? appearance.backgroundLayer.map : null;
468-
if (!mapConfig) return { isMap: false as const };
469-
return { isMap: true as const, layout: dataset.layout, coordinateMapping: mapConfig.coordinateMapping };
467+
const isMap = appearance.backgroundLayer?.type === "map";
468+
if (!isMap) return { isMap: false as const };
469+
return { isMap: true as const, layout: dataset.layout };
470470
});
471471
mapClampInputsAtom.bindEffect((inputs): undefined => {
472-
mapClampedAtom.set(inputs.isMap ? layoutNeedsClamping(inputs.layout, inputs.coordinateMapping) : false);
472+
mapClampedAtom.set(inputs.isMap ? layoutNeedsClamping(inputs.layout) : false);
473473
});
474474
export const visualGettersAtom = derivedAtom(
475475
[graphDatasetAtom, dynamicItemDataAtom, appearanceAtom],

packages/gephi-lite/src/core/layouts/collection/geographic.spec.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,36 @@ describe("Geographic layout", () => {
2828
expect(GeographicLayout.run(graph, { settings: { missingStrategy: "keep" } } as never)).toEqual({});
2929
});
3030

31-
it("should assign x=lng, y=lat for valid nodes", () => {
31+
it("should assign x=lng, y=lat with equirectangular projection", () => {
3232
const graph = makeGraph({
3333
paris: { lat: 48.85, lng: 2.35 },
3434
london: { lat: 51.5, lng: -0.12 },
3535
});
3636
const result = GeographicLayout.run(graph, {
37-
settings: { latitudeField: "lat", longitudeField: "lng", missingStrategy: "keep" },
37+
settings: {
38+
projection: "equirectangular",
39+
latitudeField: "lat",
40+
longitudeField: "lng",
41+
missingStrategy: "keep",
42+
},
3843
});
3944
expect(result).toEqual({
4045
paris: { x: 2.35, y: 48.85 },
4146
london: { x: -0.12, y: 51.5 },
4247
});
4348
});
4449

50+
it("should apply Mercator projection with default (webmercator)", () => {
51+
const graph = makeGraph({ a: { lat: 45, lng: 10 } });
52+
const result = GeographicLayout.run(graph, {
53+
settings: { latitudeField: "lat", longitudeField: "lng", missingStrategy: "keep" },
54+
});
55+
// x unchanged, y is Mercator-projected (y > lat for positive latitudes)
56+
expect(result.a.x).toBe(10);
57+
expect(result.a.y).toBeGreaterThan(45);
58+
expect(result.a.y).toBeLessThan(90);
59+
});
60+
4561
it("should skip nodes with non-numeric or NaN coordinates", () => {
4662
const graph = makeGraph({
4763
a: { lat: 48, lng: 2 },
@@ -61,7 +77,12 @@ describe("Geographic layout", () => {
6177
b: {},
6278
});
6379
const result = GeographicLayout.run(graph, {
64-
settings: { latitudeField: "lat", longitudeField: "lng", missingStrategy: "keep" },
80+
settings: {
81+
projection: "equirectangular",
82+
latitudeField: "lat",
83+
longitudeField: "lng",
84+
missingStrategy: "keep",
85+
},
6586
});
6687
expect(result).toEqual({ a: { x: 2, y: 48 } });
6788
});
@@ -74,7 +95,12 @@ describe("Geographic layout", () => {
7495
d: {},
7596
});
7697
const result = GeographicLayout.run(graph, {
77-
settings: { latitudeField: "lat", longitudeField: "lng", missingStrategy: "grid" },
98+
settings: {
99+
projection: "equirectangular",
100+
latitudeField: "lat",
101+
longitudeField: "lng",
102+
missingStrategy: "grid",
103+
},
78104
});
79105
expect(result.a).toEqual({ x: 2, y: 48 });
80106
expect(result.b).toEqual({ x: -1, y: 51 });
@@ -99,7 +125,12 @@ describe("Geographic layout", () => {
99125
],
100126
);
101127
const result = GeographicLayout.run(graph, {
102-
settings: { latitudeField: "lat", longitudeField: "lng", missingStrategy: "barycentergrid" },
128+
settings: {
129+
projection: "equirectangular",
130+
latitudeField: "lat",
131+
longitudeField: "lng",
132+
missingStrategy: "barycentergrid",
133+
},
103134
});
104135
// c is connected to a and b, should be at barycenter
105136
expect(result.c).toEqual({ x: 5, y: 50 });

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { DataGraph } from "@gephi/gephi-lite-sdk";
22

33
import { MapIcon } from "../../../components/common-icons";
4+
import { GeoProjectionType, applyGeoProjection } from "../../../utils/geo-projections";
45
import { appearanceActions } from "../../appearance";
56
import { EVENTS, emitter } from "../../context/eventsContext";
67
import { LayoutMapping, SyncLayout } from "../types";
@@ -11,6 +12,7 @@ const LNG_RE = /^(lng|lon|long|longitude|x_?coord)$/i;
1112
export type MissingStrategy = "keep" | "grid" | "barycentergrid";
1213

1314
export interface GeographicLayoutSettings {
15+
projection?: GeoProjectionType;
1416
latitudeField?: string;
1517
longitudeField?: string;
1618
missingStrategy: MissingStrategy;
@@ -39,7 +41,8 @@ function computeGridPositions(
3941
}
4042

4143
function runGeographic(graph: DataGraph, options?: { settings: GeographicLayoutSettings }): LayoutMapping {
42-
const { latitudeField, longitudeField, missingStrategy = "keep" } = options?.settings || {};
44+
const { projection = "webmercator", latitudeField, longitudeField, missingStrategy = "keep" } =
45+
options?.settings || {};
4346
const result: LayoutMapping = {};
4447

4548
if (!latitudeField || !longitudeField) return result;
@@ -51,7 +54,7 @@ function runGeographic(graph: DataGraph, options?: { settings: GeographicLayoutS
5154
const lat = attrs[latitudeField];
5255
const lng = attrs[longitudeField];
5356
if (typeof lat === "number" && typeof lng === "number" && !isNaN(lat) && !isNaN(lng)) {
54-
result[nodeId] = { x: lng, y: lat };
57+
result[nodeId] = applyGeoProjection(lng, lat, projection);
5558
validIds.push(nodeId);
5659
} else {
5760
missingIds.push(nodeId);
@@ -122,6 +125,8 @@ export const GeographicLayout = {
122125
id: "applyWithMap",
123126
description: true,
124127
icon: MapIcon,
128+
disabled: (settings: GeographicLayoutSettings) =>
129+
!!settings.projection && settings.projection !== "webmercator",
125130
onClick() {
126131
return {
127132
applyLayout: true,
@@ -136,6 +141,13 @@ export const GeographicLayout = {
136141
},
137142
],
138143
parameters: [
144+
{
145+
id: "projection",
146+
type: "enum",
147+
options: [{ id: "webmercator" }, { id: "equirectangular" }, { id: "equalearth" }, { id: "naturalearth1" }],
148+
defaultValue: "webmercator",
149+
description: true,
150+
},
139151
{
140152
id: "latitudeField",
141153
type: "attribute",

0 commit comments

Comments
 (0)