如何向图表添加悬停注释

2024-11-29 08:42:00
admin
原创
133
摘要:问题描述:我正在使用 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

相关推荐
  为什么项目管理通常仍然耗时且低效?您是否还在反复更新电子表格、淹没在便利贴中并参加每周更新会议?这确实是耗费时间和精力。借助软件工具的帮助,您可以一目了然地全面了解您的项目。如今,国内外有足够多优秀的项目管理软件可以帮助您掌控每个项目。什么是项目管理软件?项目管理软件是广泛行业用于项目规划、资源分配和调度的软件。它使项...
项目管理软件   1068  
  IPD(Integrated Product Development,集成产品开发)流程是一种广泛应用于高科技和制造业的产品开发方法论。它通过跨职能团队的紧密协作,将产品开发周期缩短,同时提高产品质量和市场成功率。在IPD流程中,CDCP(Concept Decision Checkpoint,概念决策检查点)是一个关...
IPD培训课程   6  
  研发IPD(集成产品开发)流程作为一种系统化的产品开发方法,已经在许多行业中得到广泛应用。它不仅能够提升产品开发的效率和质量,还能够通过优化流程和资源分配,显著提高客户满意度。客户满意度是企业长期成功的关键因素之一,而IPD流程通过其独特的结构和机制,能够确保产品从概念到市场交付的每个环节都围绕客户需求展开。本文将深入...
IPD流程   6  
  IPD(Integrated Product Development,集成产品开发)流程是一种以跨职能团队协作为核心的产品开发方法,旨在通过优化资源分配、提高沟通效率以及减少返工,从而缩短项目周期并提升产品质量。随着企业对产品上市速度的要求越来越高,IPD流程的应用价值愈发凸显。通过整合产品开发过程中的各个环节,IPD...
IPD项目管理咨询   6  
  跨部门沟通是企业运营中不可或缺的一环,尤其在复杂的产品开发过程中,不同部门之间的协作效率直接影响项目的成败。集成产品开发(IPD)作为一种系统化的项目管理方法,旨在通过优化流程和增强团队协作来提升产品开发的效率和质量。然而,跨部门沟通的复杂性往往成为IPD实施中的一大挑战。部门之间的目标差异、信息不对称以及沟通渠道不畅...
IPD是什么意思   6  
热门文章
项目管理软件有哪些?
云禅道AD
禅道项目管理软件

云端的项目管理软件

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

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

内置subversion和git源码管理

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

免费试用