如何向图表添加悬停注释

2024-11-29 08:42:00
admin
原创
179
摘要:问题描述:我正在使用 matplotlib 制作散点图。散点图上的每个点都与一个命名对象相关联。我希望能够在将光标悬停在与该对象相关联的散点图点上时看到该对象的名称。特别是,能够快速看到异常点的名称会很好。在这里搜索时,我能够找到的最接近的东西是注释命令,但它似乎会在图上创建一个固定标签。不幸的是,由于我拥有...

问题描述:

我正在使用 matplotlib 制作散点图。散点图上的每个点都与一个命名对象相关联。我希望能够在将光标悬停在与该对象相关联的散点图点上时看到该对象的名称。特别是,能够快速看到异常点的名称会很好。在这里搜索时,我能够找到的最接近的东西是注释命令,但它似乎会在图上创建一个固定标签。不幸的是,由于我拥有的点数,如果我标记每个点,散点图将无法读取。有谁知道一种方法可以创建仅在光标悬停在该点附近时出现的标签?


解决方案 1:

这是一个使用散点图的代码,当鼠标悬停在散点图上时会显示注释。

import matplotlib.pyplot as plt
import numpy as np; np.random.seed(1)

x = np.random.rand(15)
y = np.random.rand(15)
names = np.array(list("ABCDEFGHIJKLMNO"))
c = np.random.randint(1,5,size=15)

norm = plt.Normalize(1,4)
cmap = plt.cm.RdYlGn

fig,ax = plt.subplots()
sc = plt.scatter(x,y,c=c, s=100, cmap=cmap, norm=norm)

annot = ax.annotate("", xy=(0,0), xytext=(20,20),textcoords="offset points",
                    bbox=dict(boxstyle="round", fc="w"),
                    arrowprops=dict(arrowstyle="->"))
annot.set_visible(False)

def update_annot(ind):
    
    pos = sc.get_offsets()[ind["ind"][0]]
    annot.xy = pos
    text = "{}, {}".format(" ".join(list(map(str,ind["ind"]))), 
                           " ".join([names[n] for n in ind["ind"]]))
    annot.set_text(text)
    annot.get_bbox_patch().set_facecolor(cmap(norm(c[ind["ind"][0]])))
    annot.get_bbox_patch().set_alpha(0.4)
    

def hover(event):
    vis = annot.get_visible()
    if event.inaxes == ax:
        cont, ind = sc.contains(event)
        if cont:
            update_annot(ind)
            annot.set_visible(True)
            fig.canvas.draw_idle()
        else:
            if vis:
                annot.set_visible(False)
                fig.canvas.draw_idle()

fig.canvas.mpl_connect("motion_notify_event", hover)

plt.show()

在此处输入图片描述

因为人们也想将此解决方案用于线条plot而不是散点图,所以以下将是相同的解决方案plot(其工作方式略有不同)。

显示代码片段

import matplotlib.pyplot as plt
import numpy as np; np.random.seed(1)

x = np.sort(np.random.rand(15))
y = np.sort(np.random.rand(15))
names = np.array(list("ABCDEFGHIJKLMNO"))

norm = plt.Normalize(1,4)
cmap = plt.cm.RdYlGn

fig,ax = plt.subplots()
line, = plt.plot(x,y, marker="o")

annot = ax.annotate("", xy=(0,0), xytext=(-20,20),textcoords="offset points",
                    bbox=dict(boxstyle="round", fc="w"),
                    arrowprops=dict(arrowstyle="->"))
annot.set_visible(False)

def update_annot(ind):
    x,y = line.get_data()
    annot.xy = (x[ind["ind"][0]], y[ind["ind"][0]])
    text = "{}, {}".format(" ".join(list(map(str,ind["ind"]))), 
                           " ".join([names[n] for n in ind["ind"]]))
    annot.set_text(text)
    annot.get_bbox_patch().set_alpha(0.4)


def hover(event):
    vis = annot.get_visible()
    if event.inaxes == ax:
        cont, ind = line.contains(event)
        if cont:
            update_annot(ind)
            annot.set_visible(True)
            fig.canvas.draw_idle()
        else:
            if vis:
                annot.set_visible(False)
                fig.canvas.draw_idle()

fig.canvas.mpl_connect("motion_notify_event", hover)

plt.show()

Run code snippetHide resultsExpand snippet

如果有人正在寻找双轴线的解决方案,请参阅如何在将鼠标悬停在多轴上的某个点上时显示标签?

如果有人正在寻找条形图的解决方案,请参考例如这个答案。

解决方案 2:

此解决方案在悬停某条线时有效,无需单击它:

import matplotlib.pyplot as plt

# Need to create as global variable so our callback(on_plot_hover) can access
fig = plt.figure()
plot = fig.add_subplot(111)

# create some curves
for i in range(4):
    # Giving unique ids to each data member
    plot.plot(
        [i*1,i*2,i*3,i*4],
        gid=i)

def on_plot_hover(event):
    # Iterating over each data member plotted
    for curve in plot.get_lines():
        # Searching which data member corresponds to current mouse position
        if curve.contains(event)[0]:
            print("over %s" % curve.get_gid())
            
fig.canvas.mpl_connect('motion_notify_event', on_plot_hover)           
plt.show()

解决方案 3:

  • 使用该mplcursors包可能是最简单的选择。

    • mplcursors:阅读文档

    • mplcursors:github

    • 如果 使用Anaconda,请按照这些说明进行安装,否则请使用这些说明进行安装pip

  • 这必须在交互式窗口中绘制,而不是内联绘制。

    • %matplotlib qt对于 jupyter,在单元格中执行类似操作将打开交互式绘图。请参阅如何在 IPython 笔记本中打开交互式 matplotlib 窗口?

  • 已在python 3.10, pandas 1.4.2, matplotlib 3.5.1,测试seaborn 0.11.2

import matplotlib.pyplot as plt
import pandas_datareader as web  # only for test data; must be installed with conda or pip
from mplcursors import cursor  # separate package must be installed

# reproducible sample data as a pandas dataframe
df = web.DataReader('aapl', data_source='yahoo', start='2021-03-09', end='2022-06-13')

plt.figure(figsize=(12, 7))
plt.plot(df.index, df.Close)
cursor(hover=True)
plt.show()

在此处输入图片描述

熊猫

ax = df.plot(y='Close', figsize=(10, 7))
cursor(hover=True)
plt.show()

在此处输入图片描述

西伯恩

  • 与轴级绘图(如sns.lineplot)和图形级绘图(如)配合使用sns.relplot

import seaborn as sns

# load sample data
tips = sns.load_dataset('tips')

sns.relplot(data=tips, x="total_bill", y="tip", hue="day", col="time")
cursor(hover=True)
plt.show()

在此处输入图片描述

解决方案 4:

来自http://matplotlib.sourceforge.net/examples/event_handling/pick_event_demo.html

from matplotlib.pyplot import figure, show
import numpy as npy
from numpy.random import rand


if 1: # picking on a scatter plot (matplotlib.collections.RegularPolyCollection)

    x, y, c, s = rand(4, 100)
    def onpick3(event):
        ind = event.ind
        print('onpick3 scatter:', ind, npy.take(x, ind), npy.take(y, ind))

    fig = figure()
    ax1 = fig.add_subplot(111)
    col = ax1.scatter(x, y, 100*s, c, picker=True)
    #fig.savefig('pscoll.eps')
    fig.canvas.mpl_connect('pick_event', onpick3)

show()

解决方案 5:

其他答案没有解决我在最新版本的 Jupyter 内联 matplotlib 图中正确显示工具提示的需求。不过这个有效:

import matplotlib.pyplot as plt
import numpy as np
import mplcursors
np.random.seed(42)

fig, ax = plt.subplots()
ax.scatter(*np.random.random((2, 26)))
ax.set_title("Mouse over a point")
crs = mplcursors.cursor(ax,hover=True)

crs.connect("add", lambda sel: sel.annotation.set_text(
    'Point {},{}'.format(sel.target[0], sel.target[1])))
plt.show()

当用鼠标移到某个点上时,会出现如下图所示的情况:
在此处输入图片描述

解决方案 6:

http://matplotlib.org/users/shell.html中提供的示例进行轻微的编辑:

import numpy as np
import matplotlib.pyplot as plt

fig = plt.figure()
ax = fig.add_subplot(111)
ax.set_title('click on points')

line, = ax.plot(np.random.rand(100), '-', picker=5)  # 5 points tolerance


def onpick(event):
    thisline = event.artist
    xdata = thisline.get_xdata()
    ydata = thisline.get_ydata()
    ind = event.ind
    print('onpick points:', *zip(xdata[ind], ydata[ind]))


fig.canvas.mpl_connect('pick_event', onpick)

plt.show()

正如 Sohaib 所问的,这绘制了一条直线图

解决方案 7:

mplcursors 对我有用。mplcursors 为 matplotlib 提供可点击的注释。它深受 mpldatacursor ( https://github.com/joferkington/mpldatacursor的启发,API 大大简化

import matplotlib.pyplot as plt
import numpy as np
import mplcursors

data = np.outer(range(10), range(1, 5))

fig, ax = plt.subplots()
lines = ax.plot(data)
ax.set_title("Click somewhere on a line.
Right-click to deselect.
"
             "Annotations can be dragged.")

mplcursors.cursor(lines) # or just mplcursors.cursor()

plt.show()

解决方案 8:

mpld3 帮我解决了这个问题。

import matplotlib.pyplot as plt
import numpy as np
import mpld3

fig, ax = plt.subplots(subplot_kw=dict(axisbg='#EEEEEE'))
N = 100

scatter = ax.scatter(np.random.normal(size=N),
                 np.random.normal(size=N),
                 c=np.random.random(size=N),
                 s=1000 * np.random.random(size=N),
                 alpha=0.3,
                 cmap=plt.cm.jet)
ax.grid(color='white', linestyle='solid')

ax.set_title("Scatter Plot (with tooltips!)", size=20)

labels = ['point {0}'.format(i + 1) for i in range(N)]
tooltip = mpld3.plugins.PointLabelTooltip(scatter, labels=labels)
mpld3.plugins.connect(fig, tooltip)

mpld3.show()

您可以查看此示例:https ://mpld3.github.io/examples/scatter_tooltip.html

解决方案 9:

我已经制作了一个多行注释系统,可以添加到:https://stackoverflow.com/a/47166787/10302020。最新版本:
https ://github.com/AidenBurgess/MultiAnnotationLineGraph

只需更改底部的数据。

import matplotlib.pyplot as plt


def update_annot(ind, line, annot, ydata):
    x, y = line.get_data()
    annot.xy = (x[ind["ind"][0]], y[ind["ind"][0]])
    # Get x and y values, then format them to be displayed
    x_values = " ".join(list(map(str, ind["ind"])))
    y_values = " ".join(str(ydata[n]) for n in ind["ind"])
    text = "{}, {}".format(x_values, y_values)
    annot.set_text(text)
    annot.get_bbox_patch().set_alpha(0.4)


def hover(event, line_info):
    line, annot, ydata = line_info
    vis = annot.get_visible()
    if event.inaxes == ax:
        # Draw annotations if cursor in right position
        cont, ind = line.contains(event)
        if cont:
            update_annot(ind, line, annot, ydata)
            annot.set_visible(True)
            fig.canvas.draw_idle()
        else:
            # Don't draw annotations
            if vis:
                annot.set_visible(False)
                fig.canvas.draw_idle()


def plot_line(x, y):
    line, = plt.plot(x, y, marker="o")
    # Annotation style may be changed here
    annot = ax.annotate("", xy=(0, 0), xytext=(-20, 20), textcoords="offset points",
                        bbox=dict(boxstyle="round", fc="w"),
                        arrowprops=dict(arrowstyle="->"))
    annot.set_visible(False)
    line_info = [line, annot, y]
    fig.canvas.mpl_connect("motion_notify_event",
                           lambda event: hover(event, line_info))


# Your data values to plot
x1 = range(21)
y1 = range(0, 21)
x2 = range(21)
y2 = range(0, 42, 2)
# Plot line graphs
fig, ax = plt.subplots()
plot_line(x1, y1)
plot_line(x2, y2)
plt.show()

解决方案 10:

在 matplotlib 状态栏中显示对象信息

在此处输入图片描述

特征

  • 不需要额外的库

  • 干净的情节

  • 没有品牌和艺术家的重叠

  • 支持多艺术家标签

  • 可以处理来自不同绘图调用的艺术家(例如scatter,,plotadd_patch

  • 库风格的代码

代码

### imports
import matplotlib as mpl
import matplotlib.pylab as plt
import numpy as np


# https://stackoverflow.com/a/47166787/7128154
# https://matplotlib.org/3.3.3/api/collections_api.html#matplotlib.collections.PathCollection
# https://matplotlib.org/3.3.3/api/path_api.html#matplotlib.path.Path
# https://stackoverflow.com/questions/15876011/add-information-to-matplotlib-navigation-toolbar-status-bar
# https://stackoverflow.com/questions/36730261/matplotlib-path-contains-point
# https://stackoverflow.com/a/36335048/7128154
class StatusbarHoverManager:
    """
    Manage hover information for mpl.axes.Axes object based on appearing
    artists.

    Attributes
    ----------
    ax : mpl.axes.Axes
        subplot to show status information
    artists : list of mpl.artist.Artist
        elements on the subplot, which react to mouse over
    labels : list (list of strings) or strings
        each element on the top level corresponds to an artist.
        if the artist has items
        (i.e. second return value of contains() has key 'ind'),
        the element has to be of type list.
        otherwise the element if of type string
    cid : to reconnect motion_notify_event
    """
    def __init__(self, ax):
        assert isinstance(ax, mpl.axes.Axes)


        def hover(event):
            if event.inaxes != ax:
                return
            info = 'x={:.2f}, y={:.2f}'.format(event.xdata, event.ydata)
            ax.format_coord = lambda x, y: info
        cid = ax.figure.canvas.mpl_connect("motion_notify_event", hover)

        self.ax = ax
        self.cid = cid
        self.artists = []
        self.labels = []

    def add_artist_labels(self, artist, label):
        if isinstance(artist, list):
            assert len(artist) == 1
            artist = artist[0]

        self.artists += [artist]
        self.labels += [label]

        def hover(event):
            if event.inaxes != self.ax:
                return
            info = 'x={:.2f}, y={:.2f}'.format(event.xdata, event.ydata)
            for aa, artist in enumerate(self.artists):
                cont, dct = artist.contains(event)
                if not cont:
                    continue
                inds = dct.get('ind')
                if inds is not None:  # artist contains items
                    for ii in inds:
                        lbl = self.labels[aa][ii]
                        info += ';   artist [{:d}, {:d}]: {:}'.format(
                            aa, ii, lbl)
                else:
                    lbl = self.labels[aa]
                    info += ';   artist [{:d}]: {:}'.format(aa, lbl)
            self.ax.format_coord = lambda x, y: info

        self.ax.figure.canvas.mpl_disconnect(self.cid)
        self.cid = self.ax.figure.canvas.mpl_connect(
            "motion_notify_event", hover)



def demo_StatusbarHoverManager():
    fig, ax = plt.subplots()
    shm = StatusbarHoverManager(ax)

    poly = mpl.patches.Polygon(
        [[0,0], [3, 5], [5, 4], [6,1]], closed=True, color='green', zorder=0)
    artist = ax.add_patch(poly)
    shm.add_artist_labels(artist, 'polygon')

    artist = ax.scatter([2.5, 1, 2, 3], [6, 1, 1, 7], c='blue', s=10**2)
    lbls = ['point ' + str(ii) for ii in range(4)]
    shm.add_artist_labels(artist, lbls)

    artist = ax.plot(
        [0, 0, 1, 5, 3], [0, 1, 1, 0, 2], marker='o', color='red')
    lbls = ['segment ' + str(ii) for ii in range(5)]
    shm.add_artist_labels(artist, lbls)

    plt.show()


# --- main
if __name__== "__main__":
    demo_StatusbarHoverManager()

解决方案 11:

基于“Markus Dutschke”和“ImportanceOfBeingErnest”,我(在我看来)简化了代码并使其更加模块化。

这也不需要安装额外的软件包。

import matplotlib.pylab as plt
import numpy as np

plt.close('all')
fh, ax = plt.subplots()

#Generate some data
y,x = np.histogram(np.random.randn(10000), bins=500)
x = x[:-1]
colors = ['#0000ff', '#00ff00','#ff0000']
x2, y2 = x,y/10
x3, y3 = x, np.random.randn(500)*10+40

#Plot
h1 = ax.plot(x, y, color=colors[0])
h2 = ax.plot(x2, y2, color=colors[1])
h3 = ax.scatter(x3, y3, color=colors[2], s=1)

artists = h1 + h2 + [h3] #concatenating lists
labels = [list('ABCDE'*100),list('FGHIJ'*100),list('klmno'*100)] #define labels shown

#___ Initialize annotation arrow
annot = ax.annotate("", xy=(0,0), xytext=(20,20),textcoords="offset points",
                    bbox=dict(boxstyle="round", fc="w"),
                    arrowprops=dict(arrowstyle="->"))
annot.set_visible(False)

def on_plot_hover(event):
    if event.inaxes != ax: #exit if mouse is not on figure
        return
    is_vis = annot.get_visible() #check if an annotation is visible
    # x,y = event.xdata,event.ydata #coordinates of mouse in graph
    for ii, artist in enumerate(artists):
        is_contained, dct = artist.contains(event)

        if(is_contained):
            if('get_data' in dir(artist)): #for plot
                data = list(zip(*artist.get_data()))
            elif('get_offsets' in dir(artist)): #for scatter
                data = artist.get_offsets().data

            inds = dct['ind'] #get which data-index is under the mouse
            #___ Set Annotation settings
            xy = data[inds[0]] #get 1st position only
            annot.xy = xy
            annot.set_text(f'pos={xy},text={labels[ii][inds[0]]}')
            annot.get_bbox_patch().set_edgecolor(colors[ii])
            annot.get_bbox_patch().set_alpha(0.7)
            annot.set_visible(True)
            fh.canvas.draw_idle()
        else:
             if is_vis:
                 annot.set_visible(False) #disable when not hovering
                 fh.canvas.draw_idle()

fh.canvas.mpl_connect('motion_notify_event', on_plot_hover)

给出以下结果:
绘制 2 个高斯分布和 1 个散点图

解决方案 12:

我已经调整了 ImportanceOfBeingErnest 的答案以适用于补丁和类。特点:

  • 整个框架包含在一个类中,因此所有使用的变量仅在其相关范围内可用。

  • 可以创建多个不同的补丁集

  • 将鼠标悬停在补丁上会打印补丁集合名称和补丁子名称

  • 将鼠标悬停在某个补丁上,通过将其边缘颜色更改为黑色来突出显示该集合的所有补丁

补丁解决方案示例

注意:对于我的应用程序,重叠无关紧要,因此一次只显示一个对象的名称。如果您愿意,可以随意扩展到多个对象,这并不难。

用法

fig, ax = plt.subplots(tight_layout=True)

ap = annotated_patches(fig, ax)
ap.add_patches('Azure', 'circle', 'blue', np.random.uniform(0, 1, (4,2)), 'ABCD', 0.1)
ap.add_patches('Lava', 'rect', 'red', np.random.uniform(0, 1, (3,2)), 'EFG', 0.1, 0.05)
ap.add_patches('Emerald', 'rect', 'green', np.random.uniform(0, 1, (3,2)), 'HIJ', 0.05, 0.1)

plt.axis('equal')
plt.axis('off')

plt.show()

执行

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.collections import PatchCollection

np.random.seed(1)


class annotated_patches:
    def __init__(self, fig, ax):
        self.fig = fig
        self.ax = ax

        self.annot = self.ax.annotate("", xy=(0,0),
                            xytext=(20,20),
                            textcoords="offset points",
                            bbox=dict(boxstyle="round", fc="w"),
                            arrowprops=dict(arrowstyle="->"))
        
        self.annot.set_visible(False)
        
        self.collectionsDict = {}
        self.coordsDict = {}
        self.namesDict = {}
        self.isActiveDict = {}

        self.motionCallbackID = self.fig.canvas.mpl_connect("motion_notify_event", self.hover)

    def add_patches(self, groupName, kind, color, xyCoords, names, *params):
        if kind=='circle':
            circles = [mpatches.Circle(xy, *params, ec="none") for xy in xyCoords]
            thisCollection = PatchCollection(circles, facecolor=color, alpha=0.5, edgecolor=None)
            ax.add_collection(thisCollection)
        elif kind == 'rect':
            rectangles = [mpatches.Rectangle(xy, *params, ec="none") for xy in xyCoords] 
            thisCollection = PatchCollection(rectangles, facecolor=color, alpha=0.5, edgecolor=None)
            ax.add_collection(thisCollection)
        else:
            raise ValueError('Unexpected kind', kind)
            
        self.collectionsDict[groupName] = thisCollection
        self.coordsDict[groupName] = xyCoords
        self.namesDict[groupName] = names
        self.isActiveDict[groupName] = False
        
    def update_annot(self, groupName, patchIdxs):
        self.annot.xy = self.coordsDict[groupName][patchIdxs[0]]
        self.annot.set_text(groupName + ': ' + self.namesDict[groupName][patchIdxs[0]])
        
        # Set edge color
        self.collectionsDict[groupName].set_edgecolor('black')
        self.isActiveDict[groupName] = True

    def hover(self, event):
        vis = self.annot.get_visible()
        updatedAny = False
        if event.inaxes == self.ax:            
            for groupName, collection in self.collectionsDict.items():
                cont, ind = collection.contains(event)
                if cont:
                    self.update_annot(groupName, ind["ind"])
                    self.annot.set_visible(True)
                    self.fig.canvas.draw_idle()
                    updatedAny = True
                else:
                    if self.isActiveDict[groupName]:
                        collection.set_edgecolor(None)
                        self.isActiveDict[groupName] = True
                    
            if (not updatedAny) and vis:
                self.annot.set_visible(False)
                self.fig.canvas.draw_idle()

解决方案 13:

另一种选择是使用Plotly,它非常直观且易于使用,并且具有能够将具有悬停行为的图表保存为html文件的一大优势。

带有悬停名称结果的 plotly

import plotly.express as px

fig = px.scatter(
            x=[0, 1, 2, 3, 4], 
            y=[0, 1, 4, 9, 16],
            hover_name=['zero', 'one', 'foo', 'bar', 'baz']
)
fig.show()
fig.write_html('scatter_plot_with_hover.html')

以下是一些直观的例子:
https: //plotly.com/python/line-and-scatter/

以下是详细文档:https://plotly.com/python-api-reference/generated/plotly.express.scatter.html

相关推荐
  政府信创国产化的10大政策解读一、信创国产化的背景与意义信创国产化,即信息技术应用创新国产化,是当前中国信息技术领域的一个重要发展方向。其核心在于通过自主研发和创新,实现信息技术应用的自主可控,减少对外部技术的依赖,并规避潜在的技术制裁和风险。随着全球信息技术竞争的加剧,以及某些国家对中国在科技领域的打压,信创国产化显...
工程项目管理   1565  
  为什么项目管理通常仍然耗时且低效?您是否还在反复更新电子表格、淹没在便利贴中并参加每周更新会议?这确实是耗费时间和精力。借助软件工具的帮助,您可以一目了然地全面了解您的项目。如今,国内外有足够多优秀的项目管理软件可以帮助您掌控每个项目。什么是项目管理软件?项目管理软件是广泛行业用于项目规划、资源分配和调度的软件。它使项...
项目管理软件   1354  
  信创国产芯片作为信息技术创新的核心领域,对于推动国家自主可控生态建设具有至关重要的意义。在全球科技竞争日益激烈的背景下,实现信息技术的自主可控,摆脱对国外技术的依赖,已成为保障国家信息安全和产业可持续发展的关键。国产芯片作为信创产业的基石,其发展水平直接影响着整个信创生态的构建与完善。通过不断提升国产芯片的技术实力、产...
国产信创系统   21  
  信创生态建设旨在实现信息技术领域的自主创新和安全可控,涵盖了从硬件到软件的全产业链。随着数字化转型的加速,信创生态建设的重要性日益凸显,它不仅关乎国家的信息安全,更是推动产业升级和经济高质量发展的关键力量。然而,在推进信创生态建设的过程中,面临着诸多复杂且严峻的挑战,需要深入剖析并寻找切实可行的解决方案。技术创新难题技...
信创操作系统   27  
  信创产业作为国家信息技术创新发展的重要领域,对于保障国家信息安全、推动产业升级具有关键意义。而国产芯片作为信创产业的核心基石,其研发进展备受关注。在信创国产芯片的研发征程中,面临着诸多复杂且艰巨的难点,这些难点犹如一道道关卡,阻碍着国产芯片的快速发展。然而,科研人员和相关企业并未退缩,积极探索并提出了一系列切实可行的解...
国产化替代产品目录   28  
热门文章
项目管理软件有哪些?
云禅道AD
禅道项目管理软件

云端的项目管理软件

尊享禅道项目软件收费版功能

无需维护,随时随地协同办公

内置subversion和git源码管理

每天备份,随时转为私有部署

免费试用