Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion src/js/netjsongraph.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const NetJSONGraphDefaultConfig = {
clusterRadius: 80,
clusterSeparation: 20,
showMetaOnNarrowScreens: false,
showLabelsAtZoomLevel: 13,
showMapLabelsAtZoom: 13,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an unsolicited backward incompatible renaming

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. I am working on the fixes now. I will revert showMapLabelsAtZoom back to showLabelsAtZoomLevel and remove the unrelated formatting changes. I will force push the updates shortly to clean up the commit history.

Copy link
Member

@nemesifier nemesifier Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I was wrong about this comment, we need to read the issue description again more closely. I am sorry for this mistake, I have too many things on my mind right now and I can forget some details, let's follow the issue description and reply to the issues brought up there and use it as a guide to resolve the issue. That's the right way to do it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From #454:

(in the code we can continue supporting showLabelsAtZoomLevel for backward compatibility)

Is this being honored and if yes how?

showGraphLabelsAtZoom: null,
crs: L.CRS.EPSG3857,
echartsOption: {
Expand Down
68 changes: 62 additions & 6 deletions src/js/netjsongraph.render.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class NetJSONGraphRender {

tooltip: {
confine: true,
hideDelay: 0,
position: (pos, params, dom, rect, size) => {
let position = "right";
if (size.viewSize[0] - pos[0] < size.contentSize[0]) {
Expand Down Expand Up @@ -170,6 +171,9 @@ class NetJSONGraphRender {
const baseGraphSeries = {...configs.graphConfig.series};
const baseGraphLabel = {...(baseGraphSeries.label || {})};

// Added this for label hover issue
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is too vague... what issue? We have lots of issues with labels. What does this solve exactly?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Added this for label hover issue
// Prevent redundant overlapping labels

baseGraphLabel.silent = true;

// Shared helper to get current graph zoom level
const getGraphZoom = () => {
try {
Expand Down Expand Up @@ -332,7 +336,10 @@ class NetJSONGraphRender {
coordinateSystem: "leaflet",
data: nodesData,
animationDuration: 1000,
label: configs.mapOptions.nodeConfig.label,
label: {
...(configs.mapOptions.nodeConfig.label || {}),
silent: true,
},
itemStyle: {
color: (params) => {
if (
Expand Down Expand Up @@ -535,13 +542,28 @@ class NetJSONGraphRender {
}
}

if (self.leaflet.getZoom() < self.config.showLabelsAtZoomLevel) {
// 4. Resolve label visibility threshold
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entire block of changes is very unreadable and seems too unreadable for such a simple fix to labels.
It's a ball of spaghetti code. I don't feel comfortable in merging.

Why do we need two different variables to hide labels: showLabel and labelsDisabled? Whether it's disabled or not zoomed enough, why not just use one var? There's far too many if/else statements.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m treating labelsDisabled as a readable, single source of truth for when labels are explicitly turned off (i.e., when showLabel is false).

Since this config can be either a number or a boolean, relying on JavaScript’s truthy/falsy checks is error-prone (for example, 0 and false are indistinguishable).

So I have introduced a dedicated disable flag that lets us handle the explicit “hard disable” case consistently across render, hover, and zoom logic, without scattering === false checks throughout the code.

let {showMapLabelsAtZoom} = self.config;
if (showMapLabelsAtZoom === undefined) {
if (self.config.showMapLabelsAtZoom !== undefined) {
showMapLabelsAtZoom = self.config.showMapLabelsAtZoom;
} else {
showMapLabelsAtZoom = 13;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why? isn't the default config definition in netjsongraph.config.js enough?

}
}

let currentZoom = self.leaflet.getZoom();
let showLabel =
typeof showMapLabelsAtZoom === "number" && currentZoom >= showMapLabelsAtZoom;

if (!showLabel) {
self.echarts.setOption({
series: [
{
id: "geo-map",
label: {
show: false,
silent: true,
},
emphasis: {
label: {
Expand All @@ -553,19 +575,53 @@ class NetJSONGraphRender {
});
}

// When a user hovers over a node, we hide the static label so the Tooltip
self.echarts.on("mouseover", () => {
if (showLabel) {
self.echarts.setOption({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this have to change based on the current zoom level? Shouldn't the zoom level be evaluated in this function? What if the mouseover event happens after map rendering and the user has zoomed in or out?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I also noticed this regarding the dynamic zoom check—I'll update the handler to evaluate that live.

series: [
{
id: "geo-map",
label: {
show: false,
silent: true,
},
},
],
});
}
});

self.echarts.on("mouseout", () => {
if (showLabel) {
self.echarts.setOption({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same question here

series: [
{
id: "geo-map",
label: {
show: true,
silent: true,
},
},
],
});
}
});

self.leaflet.on("zoomend", () => {
const currentZoom = self.leaflet.getZoom();
const showLabel = currentZoom >= self.config.showLabelsAtZoomLevel;
currentZoom = self.leaflet.getZoom();
showLabel = currentZoom >= self.config.showMapLabelsAtZoom;
self.echarts.setOption({
series: [
{
id: "geo-map",
label: {
show: showLabel,
silent: true,
},
emphasis: {
label: {
show: showLabel,
show: false,
},
},
},
Expand Down Expand Up @@ -676,7 +732,7 @@ class NetJSONGraphRender {
params.data.cluster
) {
// Zoom into the clicked cluster instead of expanding it
const currentZoom = self.leaflet.getZoom();
currentZoom = self.leaflet.getZoom();
const targetZoom = Math.min(currentZoom + 2, self.leaflet.getMaxZoom());
self.leaflet.setView(
[params.data.value[1], params.data.value[0]],
Expand Down
143 changes: 137 additions & 6 deletions test/netjsongraph.render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,7 @@ describe("Test disableClusteringAtLevel: 0", () => {
leaflet: mockLeafletInstance,
echarts: {
setOption: jest.fn(),
on: jest.fn(),
_api: {
getCoordinateSystems: () => [{getLeaflet: () => mockLeafletInstance}],
},
Expand Down Expand Up @@ -1061,11 +1062,12 @@ describe("Test leaflet zoomend handler and zoom control state", () => {
onClickElement: jest.fn(),
mapOptions: {},
mapTileConfig: [{}],
showLabelsAtZoomLevel: 3,
showMapLabelsAtZoom: 3,
},
leaflet: leafletMap,
echarts: {
setOption: jest.fn(),
on: jest.fn(),
_api: {
getCoordinateSystems: jest.fn(() => [{getLeaflet: () => leafletMap}]),
},
Expand Down Expand Up @@ -1200,12 +1202,13 @@ describe("mapRender – polygon overlay & moveend bbox logic", () => {
onClickElement: jest.fn(),
mapOptions: {},
mapTileConfig: [{}],
showLabelsAtZoomLevel: 3,
showMapLabelsAtZoom: 3,
loadMoreAtZoomLevel: 4,
},
leaflet: mockLeaflet,
echarts: {
setOption: jest.fn(),
on: jest.fn(),
_api: {
getCoordinateSystems: jest.fn(() => [{getLeaflet: () => mockLeaflet}]),
},
Expand All @@ -1214,7 +1217,7 @@ describe("mapRender – polygon overlay & moveend bbox logic", () => {
isGeoJSON: jest.fn(() => true),
geojsonToNetjson: jest.fn(() => ({nodes: [], links: []})),
generateMapOption: jest.fn(() => ({series: [{data: []}]})),
echartsSetOption: jest.fn(),
echartsSetOption: jest.fn((opt) => mockSelf.echarts.setOption(opt)),
deepMergeObj: jest.fn((a, b) => ({...a, ...b})),
getBBoxData: jest.fn(() => Promise.resolve({nodes: [{id: "n1"}], links: []})),
},
Expand Down Expand Up @@ -1247,12 +1250,17 @@ describe("mapRender – polygon overlay & moveend bbox logic", () => {
// Ensure self.data exists for bbox merge logic
mockSelf.data = {nodes: [], links: []};

// Initial render calls setOption once via echartsSetOption with initial map option.
// Since zoom (5) >= threshold (3), labels are visible and no extra setOption call is made.
expect(mockSelf.echarts.setOption).toHaveBeenCalledTimes(1);

// Invoke the captured moveend callback
await capturedEvents.moveend();

expect(mockSelf.utils.getBBoxData).toHaveBeenCalled();
// After data merge, echarts.setOption should be invoked once for the update
expect(mockSelf.echarts.setOption).toHaveBeenCalledTimes(1);
// After data merge, echarts.setOption should be invoked once more for the update
// Total: 1 (initial render) + 1 (moveend update) = 2
expect(mockSelf.echarts.setOption).toHaveBeenCalledTimes(2);
// Data should now include the fetched node
expect(mockSelf.data.nodes.some((n) => n.id === "n1")).toBe(true);
});
Expand Down Expand Up @@ -1428,12 +1436,13 @@ describe("map series ids and name fallbacks", () => {
geoOptions: {},
mapOptions: {},
mapTileConfig: [{}],
showLabelsAtZoomLevel: 3,
showMapLabelsAtZoom: 3,
onClickElement: jest.fn(),
prepareData: jest.fn(),
},
echarts: {
setOption: jest.fn(),
on: jest.fn(),
_api: {
getCoordinateSystems: jest.fn(() => [{getLeaflet: () => leafletMap}]),
},
Expand All @@ -1456,3 +1465,125 @@ describe("map series ids and name fallbacks", () => {
expect(lastArg.series[0].id).toBe("geo-map");
});
});

describe("mapRender label and tooltip interaction (emphasis behavior)", () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for grouping these tests together!

let renderInstance;
let mockSelf;
let mockLeaflet;
let capturedEvents = {};

beforeEach(() => {
capturedEvents = {}; // Reset events
mockLeaflet = {
on: jest.fn((event, handler) => {
capturedEvents[event] = handler;
}),
getZoom: jest.fn(() => 15),
getMinZoom: jest.fn(() => 1),
getMaxZoom: jest.fn(() => 18),
getBounds: jest.fn(() => ({})),
getPane: jest.fn(() => undefined),
createPane: jest.fn(() => ({style: {}})),
_zoomAnimated: false,
};

mockSelf = {
type: "geojson",
data: {type: "FeatureCollection", features: []},
config: {
geoOptions: {},
mapOptions: {
nodeConfig: {
label: {show: true},
},
},
mapTileConfig: [{}],
showMapLabelsAtZoom: 13,
onClickElement: jest.fn(),
prepareData: jest.fn(),
},
leaflet: mockLeaflet,
echarts: {
setOption: jest.fn(),
on: jest.fn(), // Needed for hover test
_api: {
getCoordinateSystems: jest.fn(() => [{getLeaflet: () => mockLeaflet}]),
},
},
utils: {
deepMergeObj: jest.fn((a, b) => ({...a, ...b})),
isGeoJSON: jest.fn(() => true),
geojsonToNetjson: jest.fn(() => ({nodes: [], links: []})),
// KEY FIX: Add silent: true to the mock return so the test passes
generateMapOption: jest.fn(() => ({
series: [{id: "geo-map", label: {show: true, silent: true}}],
})),
echartsSetOption: jest.fn((opt) => mockSelf.echarts.setOption(opt)), // Link to spy
},
event: {emit: jest.fn()},
};

renderInstance = new NetJSONGraphRender();
});

test("labels are silent to prevent tooltip hover conflicts", () => {
renderInstance.mapRender(mockSelf.data, mockSelf);

const option = mockSelf.utils.generateMapOption.mock.results[0].value;
const series = option.series.find((s) => s.id === "geo-map");

// This now passes because we added silent: true to the mock above
expect(series.label.silent).toBe(true);
});

test("zoomend keeps labels silent when zoom remains above threshold", () => {
renderInstance.mapRender(mockSelf.data, mockSelf);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please avoid adding too many blank lines as they don't add any benefit

const zoomHandler = capturedEvents.zoomend;
mockLeaflet.getZoom.mockReturnValue(15);

if (zoomHandler) {
zoomHandler();
}

const lastCall = mockSelf.echarts.setOption.mock.calls.at(-1)[0];
const series = lastCall.series.find((s) => s.id === "geo-map");

// Ensure the update maintains the silent property
expect(series.label.silent).toBe(true);
});

test("hovering a node hides labels (when zoom > 13) and unhovering restores them", () => {
// 1. Setup: Zoom is high (15), so labels are visible initially
mockLeaflet.getZoom.mockReturnValue(15);
renderInstance.mapRender(mockSelf.data, mockSelf);

// 2. Get the registered event handlers
const mouseOverCall = mockSelf.echarts.on.mock.calls.find(
(c) => c[0] === "mouseover",
);
const mouseOutCall = mockSelf.echarts.on.mock.calls.find(
(c) => c[0] === "mouseout",
);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

expect(mouseOverCall).toBeDefined();
expect(mouseOutCall).toBeDefined();

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

const onHover = mouseOverCall[1];
const onUnhover = mouseOutCall[1];

// 3. Simulate Mouse Over (Tooltip appears) -> Labels should HIDE
onHover();

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

const hideCall = mockSelf.echarts.setOption.mock.calls.at(-1)[0];
const hiddenSeries = hideCall.series.find((s) => s.id === "geo-map");
expect(hiddenSeries.label.show).toBe(false);

// 4. Simulate Mouse Out (Tooltip gone) -> Labels should SHOW
onUnhover();

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

const showCall = mockSelf.echarts.setOption.mock.calls.at(-1)[0];
const shownSeries = showCall.series.find((s) => s.id === "geo-map");
expect(shownSeries.label.show).toBe(true);
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add a test for the case in which labels are disabled alltogether?

});
Loading