ericmjl / nxviz

Visualization Package for NetworkX
https://ericmjl.github.io/nxviz
MIT License
457 stars 87 forks source link

Custom color mapping circos nodes and edges #689

Closed zktuong closed 3 months ago

zktuong commented 2 years ago

This PR resolves issue #682 and #572.

PR Checklist

Please ensure that you have done the following:

  1. [x] PR in from a fork off your branch. Do not PR from :master, but rather from :.
  2. [x] If you're not on the contributors list, add yourself to AUTHORS.rst.

Code Changes

If you are adding code changes, please ensure the following:

Documentation Changes

If you are adding documentation changes, please ensure the following:

PR Description

Please describe the changes proposed in the pull request:

Hi, first just want to express my gratitude for making this fantastic package!

In this PR, I've just simply added node_palette and edge_palette kwargs with the main idea to allow circos to accept either a list or dictionary of custom color palettes for colouring categories. annotate.node_colormapping and annotate.edge_colormapping also received a similar treatment but accepts palette as the kwarg.

This gets around the issue of only limited to 12 colours and allow for custom color selection. I've inserted the kwargs into the functions in .api but I've only tried this on circos so i'm not sure if it impacts on the rest of the package.

Minimal example to illustrate the outcome:

import networkx as nx
import nxviz as nv
import matplotlib.pyplot as plt
from itertools import cycle
from nxviz import annotate

# 14 categories
categories = [
    "sun",
    "moon",
    "stars",
    "cloud",
    "wheel",
    "box",
    "plant",
    "chair",
    "slippers",
    "tablet",
    "laptop",
    "dishwasher",
    "bicycle",
    "piano",
    "laptop",
]

# 20 colors - providing an uneven list on purpose
palette = [
    "#1f77b4",
    "#ff7f0e",
    "#279e68",
    "#d62728",
    "#aa40fc",
    "#8c564b",
    "#e377c2",
    "#b5bd61",
    "#17becf",
    "#aec7e8",
    "#ffbb78",
    "#98df8a",
    "#ff9896",
    "#c5b0d5",
    "#c49c94",
    "#f7b6d2",
    "#dbdb8d",
    "#9edae5",
    "#ad494a",
    "#8c6d31",
]

categorical = cycle(categories[0:4]) # max 4 distinct categories
categories[0:4]
# ['sun', 'moon', 'stars', 'cloud']
many_categorical = cycle(categories) # up to 14

n = 71
p = 0.01
G = nx.erdos_renyi_graph(n=n, p=p)

for n in G.nodes():
    G.nodes[n]["group1"] = next(categorical)
    G.nodes[n]["group2"] = next(many_categorical) # up to 14
for u, v in G.edges():
    G.edges[u, v]["edge_group1"] = next(categorical)
    G.edges[u, v]["edge_group2"] = next(many_categorical) # up to 14
    G.edges[u, v]["thickness"] = 3  # just to be able see the edge colours later

Current default behavior as it is right now (before/after this PR) i.e. don't specify the palette options:

nv.circos(G, group_by="group1", node_color_by="group1")
annotate.node_colormapping(G, color_by="group1")

image

when there's >12 categories, just modified the error message to ask for people to provide their own palette.

nv.circos(G, group_by="group2", node_color_by="group2")

image

this PR's proposed changes:

nv.circos(G, group_by="group1", node_color_by="group1", node_palette=palette[:4]) # specify 4 colors for 4 groups

image

now with more than 12 categories (14), and a long color palette (20 colors)

nv.circos(G, group_by="group2", node_color_by="group2", node_palette=palette)

image

same as above but limit to 7 colors - colors start to cycle if palette is provided as a list.

nv.circos(G, group_by="group2", node_color_by="group2", node_palette=palette[:7])

image

if provided as a dictionary:

pal = {'moon':'red', 'stars':'yellow', 'sun':'black', 'cloud':'blue'}
nv.circos(G, group_by="group1", node_color_by="group1", node_palette=pal)
annotate.node_colormapping(G, color_by="group1", palette=pal)

image

order of keys don't matter

pal = {'moon':'red', 'cloud':'pink', 'stars':'yellow', 'sun':'black'}
nv.circos(G, group_by="group1", node_color_by="group1", node_palette=pal)
annotate.node_colormapping(G, color_by="group1", palette=pal)

image

can mix colors/hex codes

pal = ['pink', '#1f77B4', 'green', '#ff7f0e']
nv.circos(G, group_by="group1", node_color_by="group1", node_palette=pal)
annotate.node_colormapping(G, color_by="group1", palette=pal)

image

swapping of order of colors in a list matters. But the plot should reflect this correctly - if you look up at the dictionary examples, the same order is preserved.

pal = ['pink', '#1f77B4', '#ff7f0e', 'green'] # swapped the order of the last two colours
nv.circos(G, group_by="group1", node_color_by="group1", node_palette=pal)
annotate.node_colormapping(G, color_by="group1", palette=pal)

image

Can be used on edges as well:

nv.circos(G, 
          group_by="group2", 
          node_color_by="group2", 
          edge_color_by = 'edge_group1', 
          node_palette=palette, 
          edge_palette = palette,
          edge_lw_by = 'thickness',
         )
annotate.node_colormapping(G, color_by="group2", palette=palette)
annotate.edge_colormapping(G, color_by="edge_group1", palette=palette)

image

As a sanity check, just to ensure that the color/order is respected you can see in this small graph where only 2 categories will be repeated twice (sun and moon), the color mapping remains consistent:

default i.e. palette not provided

n = 6
p = .01
G = nx.erdos_renyi_graph(n=n, p=p)
assignments = []
for n in G.nodes():
    G.nodes[n]["group1"] = next(categorical)
    assignments.append(G.nodes[n]["group1"])
    G.nodes[n]["group2"] = next(many_categorical)
assignments
# ['sun', 'moon', 'stars', 'cloud', 'sun', 'moon']
nv.circos(G, group_by="group1", node_color_by="group1")
annotate.node_colormapping(G, color_by="group1")

image

palette provided as a list. Because sun was added first (it was added sequentially as 'sun', 'moon', 'stars', 'cloud', 'sun', 'moon'), it should be pink. cloud is the last unique entry, so should be green.

pal = ['pink', '#1f77B4', '#ff7f0e', 'green']
nv.circos(G, group_by="group1", node_color_by="group1", node_palette=pal)
annotate.node_colormapping(G, color_by="group1", palette=pal)

image

palette is provided as a dictionary.

pal = {'moon':'red', 'stars':'yellow', 'sun':'black', 'cloud':'blue'}
nv.circos(G, group_by="group1", node_color_by="group1", node_palette=pal)
annotate.node_colormapping(G, color_by="group1", palette=pal)

image

let me know what you think!

Cheers, Kelvin

Relevant Reviewers

Please tag maintainers to review.

zktuong commented 3 months ago

Thank you! Not sure how much i can contribute but will try!