diff --git a/src/wcp-web/src/main/java/com/farm/wcp/controller/AIController.java b/src/wcp-web/src/main/java/com/farm/wcp/controller/AIController.java new file mode 100644 index 0000000..8e71d65 --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/controller/AIController.java @@ -0,0 +1,407 @@ +//AI1·模块 +// 定义包名,该控制器类位于 com.farm.wcp.controller 包下 +package com.farm.wcp.controller; + +// 导入 java.util 包下的相关类,用于处理集合和日期 +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +// 导入 javax.annotation 包下的 Resource 注解,用于资源注入 +import javax.annotation.Resource; +// 导入 javax.servlet.http 包下的 HttpServletRequest 和 HttpSession 类,用于处理 HTTP 请求和会话 +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +// 导入 org.apache.commons.lang 包下的 StringUtils 类,用于字符串处理 +import org.apache.commons.lang.StringUtils; +// 导入 org.apache.log4j 包下的 Logger 类,用于日志记录 +import org.apache.log4j.Logger; +// 导入 org.jsoup 包下的相关类,用于解析 HTML +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +// 导入 org.springframework 包下的相关注解和类,用于 Spring MVC 开发 +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.servlet.ModelAndView; + +// 导入 com.farm.core 包下的相关类,用于用户认证、页面视图和 SQL 结果处理 +import com.farm.core.auth.domain.LoginUser; +import com.farm.core.page.ViewMode; +import com.farm.core.sql.result.DataResult; +// 导入 com.farm.core.time 包下的 TimeTool 类,用于时间处理 +import com.farm.core.time.TimeTool; +// 导入 com.farm.doc 包下的相关接口,用于文档索引、管理和类型处理 +import com.farm.doc.server.FarmDocIndexInter; +import com.farm.doc.server.FarmDocManagerInter; +import com.farm.doc.server.FarmDocTypeInter; +// 导入 com.farm.doc.util 包下的 HtmlUtils 类,用于 HTML 处理 +import com.farm.doc.util.HtmlUtils; +// 导入 com.farm.llm.utils 包下的 LlmMessage 类及其内部枚举 M_TYPE,用于处理大语言模型消息 +import com.farm.llm.utils.LlmMessage; +import com.farm.llm.utils.LlmMessage.M_TYPE; +// 导入 com.farm.parameter 包下的 FarmParameterService 类,用于获取系统参数 +import com.farm.parameter.FarmParameterService; +// 导入 com.farm.tex 包下的相关类,用于 AI 问答和消息处理 +import com.farm.tex.AiQuestor; +import com.farm.tex.domainex.AiQuestorMessage; +import com.farm.tex.domainex.AiQuestorMessage.MessageState; +// 导入 com.farm.wcp.util 包下的相关类,用于即时通讯历史记录和主题处理 +import com.farm.wcp.util.ImHistory; +import com.farm.wcp.util.ThemesUtil; +// 导入 com.farm.web 包下的 WebUtils 类,用于 Web 相关工具方法 +import com.farm.web.WebUtils; + +/** + * 智能对话服务控制器类 + * + * @author wangdogn + */ +// 定义请求映射的根路径为 /aiweb +@RequestMapping("/aiweb") +// 标记该类为 Spring MVC 控制器 +@Controller +// 继承 WebUtils 类,获取 Web 相关工具方法 +public class AIController extends WebUtils { + + // 定义日志记录器,用于记录该类的日志信息 + private static final Logger log = Logger.getLogger(AIController.class); + // 使用 @Resource 注解注入 FarmDocIndexInter 接口的实现类实例 + @Resource + private FarmDocIndexInter farmDocIndexManagerImpl; + // 使用 @Resource 注解注入 FarmDocTypeInter 接口的实现类实例 + @Resource + private FarmDocTypeInter farmDocTypeManagerImpl; + // 使用 @Resource 注解注入 FarmDocManagerInter 接口的实现类实例 + @Resource + private FarmDocManagerImpl; + + /** + * 获取主题路径 + * + * @return 主题路径 + */ + public static String getThemePath() { + // 从系统参数中获取主题路径 + return FarmParameterService.getInstance().getParameter("config.sys.web.themes.path"); + } + + /** + * 独立的 AI 对话页面 + * + * @param docid 文档 ID + * @param actiontype 操作类型 + * @param session HTTP 会话 + * @param request HTTP 请求 + * @return 模型视图对象 + * @throws Exception 可能抛出的异常 + */ + // 定义请求映射,处理 GET 请求,路径为 /PubAiChat + @RequestMapping(value = "/PubAiChat", method = RequestMethod.GET) + public ModelAndView aiChat(String docid, String actiontype, HttpSession session, HttpServletRequest request) + throws Exception { + try { + // 返回 AI 对话页面的模型视图 + return ViewMode.getInstance().returnModelAndView("web-simple/aichat/aiChat"); + } catch (Exception e) { + // 若出现异常,设置错误信息并返回错误页面的模型视图 + return ViewMode.getInstance().setError(e.toString()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + } + + /** + * 发送消息,并接收回复 + * + * @param message 用户发送的消息 + * @param session HTTP 会话 + * @param request HTTP 请求 + * @return 包含回复消息的 Map 对象 + */ + // 定义请求映射,路径为 /PubSendmgs + @RequestMapping("/PubSendmgs") + // 将返回值直接作为响应体返回 + @ResponseBody + public Map StatCommitTestDate(String message, HttpSession session, HttpServletRequest request) { + // 初始化回复消息为空 + String backmessage = null; + // 获取对话会话 ID + String talkSessionId = AiQuestor.getTalkSessionId(session, request); + // 判断是否强制登录且用户未登录 + if (FarmParameterService.getInstance().getParameterBoolean("config.imbar.login.force") + && getCurrentUser(session) == null) { + // 若未登录,返回必须登录的提示信息 + return ViewMode.getInstance().putAttr("msg", "
请 登录 账户
") + .returnObjMode(); + } + // 向会话缓存中添加用户发送的消息历史记录 + ImHistory.addMessage(session, message, message, LlmMessage.M_TYPE.USER); + // 获取即时通讯栏类型参数 + if (FarmParameterService.getInstance().getParameter("config.imbar.type").equals("llm")) { + // 判断当前是否存在未完成的会话 + if (AiQuestor.talking(talkSessionId) != null) { + // 若存在未完成的会话,返回提示信息 + return ViewMode.getInstance() + .putAttr("msg", AiQuestor.talking(talkSessionId).getHtml() + "(当前存在未完成的会话)") + .returnObjMode(); + } + // 进行 AI 问答,发送消息并获取回复 + AiQuestorMessage bmessage = AiQuestor.send(message, talkSessionId, null, ImHistory.getMessages(session)); + // 获取回复消息的 HTML 内容 + backmessage = bmessage.getHtml(); + } + + // 判断回复消息为空、包含错误信息或即时通讯栏类型为 search + if (backmessage == null || backmessage.indexOf("[ERROR]") == 0 + || FarmParameterService.getInstance().getParameter("config.imbar.type").equals("search")) { + // 本地查询接口,初始化查询结果为空 + String searchRrt = null; + try { + // 调用文档索引管理器进行搜索 + DataResult result = farmDocIndexManagerImpl.search(message, + getCurrentUser(session, request) == null ? null : getCurrentUser(session, request).getId(), 1); + // 从搜索结果中获取知识链接 + String linkKnows = getKnowLinksBySearchResult(result.getResultList()); + if (StringUtils.isNotBlank(linkKnows)) { + // 若找到相关知识,设置查询结果为知识链接 + searchRrt = "找到以下知识:

" + linkKnows; + } else { + // 若未找到相关知识,设置查询结果为提示信息 + searchRrt = "暂未找到相关知识"; + } + } catch (Exception e) { + // 若出现异常,打印异常堆栈信息 + e.printStackTrace(); + } + + if (StringUtils.isBlank(backmessage)) { + // 若回复消息为空,将查询结果作为回复消息 + backmessage = searchRrt; + } else { + // 若回复消息不为空,将查询结果追加到回复消息后面 + backmessage = backmessage + "
" + searchRrt; + } + + // 创建新的 AI 问答消息对象 + AiQuestorMessage bmessage = new AiQuestorMessage(talkSessionId); + // 设置消息内容 + bmessage.setMessage(backmessage); + // 提交消息,标记为已完成状态 + bmessage.submit(MessageState.COMPELET); + // 向会话缓存中添加回复消息的历史记录 + ImHistory.addMessage(session, bmessage.getHtmlmsg(), HtmlUtils.HtmlRemoveTag(bmessage.getMessage()), + LlmMessage.M_TYPE.ASSISTANT); + } + // 将回复消息放入 Map 对象并返回 + return ViewMode.getInstance().putAttr("msg", backmessage).returnObjMode(); + } + + /** + * 加载当前消息 + * + * @param ids 消息 ID 列表,多个 ID 用逗号分隔 + * @param session HTTP 会话 + * @param request HTTP 请求 + * @return 包含消息列表的 Map 对象 + */ + // 定义请求映射,路径为 /PubLoadmsg + @RequestMapping("/PubLoadmsg") + // 将返回值直接作为响应体返回 + @ResponseBody + public Map loadmsg(String ids, HttpSession session, HttpServletRequest request) { + try { + // 初始化消息列表 + List msgs = new ArrayList(); + // 解析消息 ID 列表 + for (String id : parseIds(ids)) { + // 加载指定 ID 的消息 + AiQuestorMessage bmessage = AiQuestor.loadMessage(id); + // 判断消息状态是否为已完成 + if (bmessage.getState().equals(MessageState.COMPELET)) { + // 若消息已完成,将消息添加到会话缓存的历史记录中 + ImHistory.addMessage(session, bmessage.getHtmlmsg(), HtmlUtils.HtmlRemoveTag(bmessage.getMessage()), + LlmMessage.M_TYPE.ASSISTANT); + // 标记消息为已过期 + bmessage.submit(MessageState.EXPIRE); + } + // 将消息添加到消息列表中 + msgs.add(bmessage); + } + // 将消息列表放入 Map 对象并返回 + return ViewMode.getInstance().putAttr("msgs", msgs).returnObjMode(); + } catch (Exception e) { + // 若出现异常,设置错误信息并返回 + return ViewMode.getInstance().setError(e.getMessage()).returnObjMode(); + } + } + + /** + * 拼接一个供 GPT 参考的知识内容,从查询结果中抽取该知识 + * + * @param list 查询结果列表,每个元素为一个包含文档信息的 Map 对象 + * @return 拼接后的知识链接字符串 + */ + private String getKnowLinksBySearchResult(List> list) { + // 初始化知识链接字符串为空 + String know = null; + // 遍历查询结果列表 + for (Map node : list) { + // 获取文档标题 + String title = (String) node.get("TITLE"); + // 去除标题中的 HTML 标签和双引号 + title = HtmlUtils.HtmlRemoveTag(title).replace("\"", ""); + // 获取文档描述 + String docdescribe = (String) node.get("DOCDESCRIBE"); + // 去除文档描述中的 HTML 标签和双引号 + docdescribe = HtmlUtils.HtmlRemoveTag(docdescribe).replace("\"", ""); + // 获取文档 ID + String ID = (String) node.get("ID"); + // 获取文档类型 + String DOMTYPE = (String) node.get("DOMTYPE"); + // 初始化文档链接为空 + String url = null; + // 根据文档类型生成文档链接 + if (DOMTYPE.equals("file")) { + url = "webdoc/view/PubFile" + ID + ".html"; + } + if (DOMTYPE.equals("fqa")) { + url = "webquest/fqa/Pub" + ID + ".html"; + } + if (!DOMTYPE.equals("fqa") && !DOMTYPE.equals("file")) { + url = "webdoc/view/Pub" + ID + ".html"; + } + // 生成知识链接的 HTML 代码 + String a = "" + title + "
"; + if (know == null) { + // 若知识链接字符串为空,将当前链接作为初始值 + know = a; + } else { + // 若知识链接字符串不为空,将当前链接追加到后面 + know = know + a; + } + } + // 返回拼接后的知识链接字符串 + return know; + } + + /** + * 清理历史消息 + * + * @param session HTTP 会话 + * @param request HTTP 请求 + * @return 包含操作结果的 Map 对象 + */ + // 定义请求映射,路径为 /PubClear + @RequestMapping("/PubClear") + // 将返回值直接作为响应体返回 + @ResponseBody + public Map PubClear(HttpSession session, HttpServletRequest request) { + // 调用 ImHistory 类的方法清理会话缓存中的历史消息 + ImHistory.clearMessage(session); + // 返回操作结果 + return ViewMode.getInstance().returnObjMode(); + } + + /** + * 立即停止回答 + * + * @param ids 消息 ID 列表,多个 ID 用逗号分隔 + * @param session HTTP 会话 + * @param request HTTP 请求 + * @return 包含操作结果的 Map 对象 + */ + // 定义请求映射,路径为 /PubStopMsg + @RequestMapping("/PubStopMsg") + // 将返回值直接作为响应体返回 + @ResponseBody + public Map PubStopMsg(String ids, HttpSession session, HttpServletRequest request) { + try { + // 解析消息 ID 列表 + for (String id : parseIds(ids)) { + // 调用 AiQuestorMessage 类的方法停止指定 ID 的消息回答 + AiQuestorMessage.stop(id); + } + // 返回操作结果 + return ViewMode.getInstance().returnObjMode(); + } catch (Exception e) { + // 若出现异常,设置错误信息并返回 + return ViewMode.getInstance().setError(e.getMessage()).returnObjMode(); + } + } + + /** + * 加载历史消息 + * + * @param session HTTP 会话 + * @param request HTTP 请求 + * @return 包含历史消息列表的 Map 对象 + */ + // 定义请求映射,路径为 /PubLoadhis + @RequestMapping("/PubLoadhis") + // 将返回值直接作为响应体返回 + @ResponseBody + public Map PubLoadhis(HttpSession session, HttpServletRequest request) { + // 从会话缓存中获取历史消息列表 + List list = ImHistory.getMessages(session); + if (list.size() <= 0) { + // 若历史消息列表为空,获取系统配置的默认提示信息 + String key = FarmParameterService.getInstance().getParameter("config.imbar.default.tip"); + if (!key.trim().toLowerCase().equals("none")) { + // 若默认提示信息不为 none,创建新的消息对象 + LlmMessage message = null; + // 获取知识库默认提示消息 + if (message == null) { + message = new LlmMessage(key, M_TYPE.FUNCTIP); + } + // 将默认提示消息添加到历史消息列表中 + list.add(message); + } + } + // 将历史消息列表放入 Map 对象并返回 + return ViewMode.getInstance().putAttr("msgs", list).returnObjMode(); + } + + /** + * 获取当前用户 + * + * @param session HTTP 会话 + * @param request HTTP 请求 + * @return 当前用户对象 + */ + private LoginUser getCurrentUser(HttpSession session, final HttpServletRequest request) { + // 初始化用户对象为空 + LoginUser user = null; + // 判断会话中是否存在当前用户 + if (getCurrentUser(session) != null) { + // 若存在,将其赋值给 user 对象 + user = getCurrentUser(session); + } else { + // 若不存在,创建一个匿名用户对象 + user = new LoginUser() { + + @Override + public String getName() { + return null; + } + + @Override + public String getLoginname() { + return null; + } + + @Override + public String getId() { + return null; + } + }; + } + // 返回当前用户对象 + return user; + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/wcp/controller/DocController.java b/src/wcp-web/src/main/java/com/farm/wcp/controller/DocController.java new file mode 100644 index 0000000..95357bd --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/controller/DocController.java @@ -0,0 +1,471 @@ +// 定义包名,表明该类所属的包路径 +package com.farm.wcp.controller; + +// 导入 HashSet 类,用于存储不重复元素的集合 +import java.util.HashSet; +// 导入 List 接口,用于存储有序元素集合 +import java.util.List; +// 导入 Map 接口,用于存储键值对 +import java.util.Map; +// 导入 Set 接口,用于存储不重复元素的集合 +import java.util.Set; + +// 导入 Resource 注解,用于资源注入 +import javax.annotation.Resource; +// 导入 HttpServletRequest 类,用于封装 HTTP 请求信息 +import javax.servlet.http.HttpServletRequest; +// 导入 HttpSession 类,用于管理用户会话 +import javax.servlet.http.HttpSession; + +// 导入 Logger 类,用于日志记录 +import org.apache.log4j.Logger; +// 导入 Controller 注解,标记该类为 Spring MVC 控制器 +import org.springframework.stereotype.Controller; +// 导入 PathVariable 注解,用于获取请求路径中的变量 +import org.springframework.web.bind.annotation.PathVariable; +// 导入 RequestMapping 注解,用于映射请求路径和处理方法 +import org.springframework.web.bind.annotation.RequestMapping; +// 导入 RequestMethod 枚举,用于指定请求方法 +import org.springframework.web.bind.annotation.RequestMethod; +// 导入 ResponseBody 注解,用于将返回值直接作为响应体返回 +import org.springframework.web.bind.annotation.ResponseBody; +// 导入 ModelAndView 类,用于封装模型数据和视图信息 +import org.springframework.web.servlet.ModelAndView; + +// 导入 LoginUser 类,用于表示登录用户信息 +import com.farm.core.auth.domain.LoginUser; +// 导入 ViewMode 类,用于处理视图模式 +import com.farm.core.page.ViewMode; +// 导入 FarmRfDoctypeDaoInter 接口,用于文档类型关联数据访问 +import com.farm.doc.dao.FarmRfDoctypeDaoInter; +// 导入 FarmDocfile 类,用于表示文档附件信息 +import com.farm.doc.domain.FarmDocfile; +// 导入 FarmDocruninfo 类,用于表示文档运行信息 +import com.farm.doc.domain.FarmDocruninfo; +// 导入 FarmDoctext 类,用于表示文档文本信息 +import com.farm.doc.domain.FarmDoctext; +// 导入 FarmDoctype 类,用于表示文档类型信息 +import com.farm.doc.domain.FarmDoctype; +// 导入 DocBrief 类,用于表示文档简要信息 +import com.farm.doc.domain.ex.DocBrief; +// 导入 DocEntire 类,用于表示完整文档信息 +import com.farm.doc.domain.ex.DocEntire; +// 导入 CanNoReadException 异常类,用于表示无权限读取文档异常 +import com.farm.doc.exception.CanNoReadException; +// 导入 DocNoExistException 异常类,用于表示文档不存在异常 +import com.farm.doc.exception.DocNoExistException; +// 导入 FarmDocIndexInter 接口,用于文档索引操作 +import com.farm.doc.server.FarmDocIndexInter; +// 导入 FarmDocManagerInter 接口,用于文档管理操作 +import com.farm.doc.server.FarmDocManagerInter; +// 导入 FarmDocOperateRightInter 接口,用于文档操作权限管理 +import com.farm.doc.server.FarmDocOperateRightInter; +// 导入 FarmDocRunInfoInter 接口,用于文档运行信息管理 +import com.farm.doc.server.FarmDocRunInfoInter; +// 导入 FarmDocgroupManagerInter 接口,用于文档组管理操作 +import com.farm.doc.server.FarmDocgroupManagerInter; +// 导入 FarmDocmessageManagerInter 接口,用于文档消息管理操作 +import com.farm.doc.server.FarmDocmessageManagerInter; +// 导入 FarmFileIndexManagerInter 接口,用于文件索引管理操作 +import com.farm.doc.server.FarmFileIndexManagerInter; +// 导入 FarmFileManagerInter 接口,用于文件管理操作 +import com.farm.doc.server.FarmFileManagerInter; +// 导入 KnowServiceInter 接口,用于知识服务操作 +import com.farm.wcp.know.service.KnowServiceInter; +// 导入 ThemesUtil 类,用于主题相关工具操作 +import com.farm.wcp.util.ThemesUtil; +// 导入 WebUtils 类,用于 Web 相关工具操作 +import com.farm.web.WebUtils; + +// 定义请求映射的根路径为 /webdoc +@RequestMapping("/webdoc") +// 标记该类为 Spring MVC 控制器 +@Controller +// 继承 WebUtils 类,获取 Web 相关工具方法 +public class DocController extends WebUtils { + // 定义日志记录器,用于记录该类的日志信息 + private final static Logger log = Logger.getLogger(DocController.class); + // 使用 @Resource 注解注入 FarmDocgroupManagerInter 接口的实现类实例 + @Resource + private FarmDocgroupManagerInter farmDocgroupManagerImpl; + // 使用 @Resource 注解注入 FarmFileManagerInter 接口的实现类实例 + @Resource + private FarmFileManagerInter farmFileManagerImpl; + // 使用 @Resource 注解注入 FarmDocManagerInter 接口的实现类实例 + @Resource + private FarmDocManagerInter farmDocManagerImpl; + // 使用 @Resource 注解注入 FarmDocRunInfoInter 接口的实现类实例 + @Resource + private FarmDocRunInfoInter farmDocRunInfoImpl; + // 使用 @Resource 注解注入 KnowServiceInter 接口的实现类实例 + @Resource + private KnowServiceInter KnowServiceImpl; + // 使用 @Resource 注解注入 FarmDocmessageManagerInter 接口的实现类实例 + @Resource + private FarmDocmessageManagerInter farmDocmessageManagerImpl; + // 使用 @Resource 注解注入 FarmDocOperateRightInter 接口的实现类实例 + @Resource + private FarmDocOperateRightInter farmDocOperateRightImpl; + // 使用 @Resource 注解注入 FarmDocIndexInter 接口的实现类实例 + @Resource + private FarmDocIndexInter farmDocIndexManagerImpl; + // 使用 @Resource 注解注入 FarmRfDoctypeDaoInter 接口的实现类实例 + @Resource + private FarmRfDoctypeDaoInter farmRfDoctypeDaoImpl; + // 使用 @Resource 注解注入 FarmFileIndexManagerInter 接口的实现类实例 + @Resource + private FarmFileIndexManagerInter farmFileIndexManagerImpl; + + /** + * 查看知识 + * + * @param docid 文档 ID + * @param session HTTP 会话 + * @param request HTTP 请求 + * @return 模型视图对象 + * @throws Exception 可能抛出的异常 + */ + // 定义请求映射,处理 GET 请求,路径为 /view/Pub{docid} + @RequestMapping(value = "/view/Pub{docid}", method = RequestMethod.GET) + public ModelAndView showDoc(@PathVariable("docid") String docid, HttpSession session, HttpServletRequest request) + throws Exception { + // 获取视图模式实例 + ViewMode page = ViewMode.getInstance(); + try { + // 根据文档 ID 和当前用户获取完整文档信息 + DocEntire doc = farmDocManagerImpl.getDoc(docid, getCurrentUser(session)); + // 将完整文档信息放入视图模式的属性中 + page.putAttr("DOCE", doc); + // 根据文档 ID 获取文档的所有版本信息 + List versions = farmDocManagerImpl.getDocVersions(docid); + // 将文档版本信息放入视图模式的属性中 + page.putAttr("VERSIONS", versions); + // 判断当前用户是否存在 + if (getCurrentUser(session) != null) { + // 判断当前用户是否喜欢该文档 + boolean isenjoy = farmDocRunInfoImpl.isEnjoyDoc(getCurrentUser(session).getId(), docid); + // 将是否喜欢该文档的信息放入视图模式的属性中 + page.putAttr("ISENJOY", isenjoy); + } + // 获取文档的类型信息 + FarmDoctype type = doc.getType(); + // 将文档类型 ID 放入视图模式的属性中,若类型为空则放入空字符串 + page.putAttr("TYPEID", type == null ? "" : type.getId()); + // 创建一个 HashSet 用于存储文档附件的文件类型 + Set fileTypes = new HashSet(); + // 遍历文档的所有附件 + for (FarmDocfile node : doc.getFiles()) { + // 提取附件的文件扩展名,去除点号并转换为大写后添加到文件类型集合中 + fileTypes.add(node.getExname().trim().replace(".", "").toUpperCase()); + } + // 将文件类型集合放入视图模式的属性中 + page.putAttr("FILETYPES", fileTypes); + // 记录文档访问信息,包括文档 ID、当前用户和用户 IP 地址 + farmDocRunInfoImpl.visitDoc(docid, getCurrentUser(session), getCurrentIp(request)); + // 获取当前用户信息 + LoginUser user = getCurrentUser(session); + // 判断文档类型是否存在 + if (type != null) { + // 获取与该文档类型相关的其他文档简要信息 + List typedocs = farmDocRunInfoImpl.getTypeDocs(type == null ? "" : type.getId(), + user == null ? "none" : user.getId(), 10); + // 将相关文档简要信息放入视图模式的属性中 + page.putAttr("TYPEDOCS", typedocs); + } + // 判断文档的领域类型是否为 1 + if (doc.getDoc().getDomtype().equals("1")) { + // 若为 1,则返回知识查看页面的模型视图 + return page.returnModelAndView(ThemesUtil.getThemePath() + "/know/view"); + } + // 判断文档的领域类型是否为 5 + if (doc.getDoc().getDomtype().equals("5")) { + // 若为 5,则返回文件查看页面的模型视图 + return page.returnModelAndView(ThemesUtil.getThemePath() + "/webfile/webfile"); + } + // 判断文档的领域类型是否为 4 + if (doc.getDoc().getDomtype().equals("4")) { + // 若为 4,则将文档组 ID 放入视图模式的属性中,并重定向到文档组展示页面 + return page.putAttr("groupid", doc.getGroup().getId()).returnRedirectUrl("/webgroup/Pubshow.do"); + } + } catch (CanNoReadException e) { + // 若捕获到无权限读取文档异常,设置错误信息并返回错误页面的模型视图 + return ViewMode.getInstance().setError(e.toString()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } catch (DocNoExistException e) { + // 若捕获到文档不存在异常,设置错误信息并返回错误页面的模型视图 + return ViewMode.getInstance().setError(e.toString()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } catch (Exception e) { + // 若捕获到其他异常,打印异常堆栈信息,设置错误信息并返回错误页面的模型视图 + e.printStackTrace(); + return ViewMode.getInstance().setError(e.toString()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + // 若未匹配到正确的文档类型解析,设置错误信息并返回错误页面的模型视图 + return ViewMode.getInstance().setError("请实现正确的DOCTYPE类型解析") + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + + /** + * 查看附件 + * + * @param fileid 附件 ID + * @param session HTTP 会话 + * @param request HTTP 请求 + * @return 模型视图对象 + * @throws Exception 可能抛出的异常 + */ + // 定义请求映射,处理 GET 请求,路径为 /view/PubFile{fileid} + @RequestMapping(value = "/view/PubFile{fileid}", method = RequestMethod.GET) + public ModelAndView showFile(@PathVariable("fileid") String fileid, HttpSession session, HttpServletRequest request) + throws Exception { + // 获取视图模式实例 + ViewMode page = ViewMode.getInstance(); + try { + // 根据附件 ID 获取附件信息 + FarmDocfile file = farmFileManagerImpl.getFile(fileid); + // 判断附件是否存在 + if (file == null) { + // 若附件不存在,抛出文档不存在异常 + throw new DocNoExistException(); + } + // 设置附件的访问 URL + file.setUrl(farmFileManagerImpl.getFileURL(file.getId())); + // 将附件信息放入视图模式的属性中 + page.putAttr("file", file); + } catch (DocNoExistException e) { + // 若捕获到文档不存在异常,记录错误信息,删除附件的 Lucene 索引,设置错误信息并返回错误页面的模型视图 + log.error(e.getMessage()); + farmFileIndexManagerImpl.delFileLucenneIndex(fileid); + return ViewMode.getInstance().setError(e.toString()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } catch (Exception e) { + // 若捕获到其他异常,记录错误信息,设置错误信息并返回错误页面的模型视图 + log.error(e.getMessage()); + return ViewMode.getInstance().setError(e.toString()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + // 返回文件查看页面的模型视图 + return page.returnModelAndView(ThemesUtil.getThemePath() + "/webfile/file"); + } + + /** + * 喜欢文档 + * + * @param session HTTP 会话 + * @param id 文档 ID + * @return 包含操作结果的 Map 对象 + */ + // 定义请求映射,路径为 /enjoy + @RequestMapping("/enjoy") + // 将返回值直接作为响应体返回 + @ResponseBody + public Map enjoy(HttpSession session, String id) { + try { + // 当前用户喜欢指定文档 + farmDocRunInfoImpl.enjoyDoc(getCurrentUser(session).getId(), id); + // 将操作类型设为 0(成功),返回操作结果 + return ViewMode.getInstance().putAttr("commitType", "0").returnObjMode(); + } catch (Exception e) { + // 若出现异常,将操作类型设为 1(失败),设置错误信息并返回操作结果 + return ViewMode.getInstance().putAttr("commitType", "1").setError(e.getMessage()).returnObjMode(); + } + } + + /** + * 取消喜欢文档 + * + * @param session HTTP 会话 + * @param id 文档 ID + * @return 包含操作结果的 Map 对象 + */ + // 定义请求映射,路径为 /FLunEnjoy + @RequestMapping("/FLunEnjoy") + // 将返回值直接作为响应体返回 + @ResponseBody + public Map unenjoy(HttpSession session, String id) { + try { + // 当前用户取消喜欢指定文档 + farmDocRunInfoImpl.unEnjoyDoc(getCurrentUser(session).getId(), id); + // 将操作类型设为 0(成功),返回操作结果 + return ViewMode.getInstance().putAttr("commitType", "0").returnObjMode(); + } catch (Exception e) { + // 若出现异常,将操作类型设为 1(失败),设置错误信息并返回操作结果 + return ViewMode.getInstance().putAttr("commitType", "1").setError(e.getMessage()).returnObjMode(); + } + } + + /** + * 查看文档版本 + * + * @param textid 文档文本 ID + * @param session HTTP 会话 + * @param request HTTP 请求 + * @return 模型视图对象 + */ + // 定义请求映射,路径为 /PubVersion + @RequestMapping("/PubVersion") + ; public ModelAndView showVersion(String textid, HttpSession session, HttpServletRequest request) { + // 获取视图模式实例 + ViewMode page = ViewMode.getInstance(); + try { + // 根据文档文本 ID 和当前用户获取文档版本信息 + DocEntire doc = farmDocManagerImpl.getDocVersion(textid, getCurrentUser(session)); + // 判断文档状态是否不为 1(可访问状态) + if (!doc.getDoc().getState().equals("1")) { + // 若状态不符,抛出运行时异常 + throw new RuntimeException("没有权限访问该文档"); + } + // 根据文档 ID 获取文档的所有版本信息 + List versions = farmDocManagerImpl.getDocVersions(doc.getDoc().getId()); + // 将文档版本信息放入视图模式的属性中 + page.putAttr("VERSIONS", versions); + // 将完整文档信息放入视图模式的属性中,并返回文档版本查看页面的模型视图 + return page.putAttr("DOCE", doc).returnModelAndView(ThemesUtil.getThemePath() + "/know/version"); + } catch (Exception e) { + // 若出现异常,设置错误信息并返回错误页面的模型视图 + return page.setError(e.getMessage()).returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + } + + /** + * 公开文档(将该文档开放阅读和编辑权限,同时如果是小组文档将删除小组所有权) + * + * @param id 文档 ID + * @param session HTTP 会话 + * @return 模型视图对象 + */ + // 定义请求映射,路径为 /FLflyKnow + @RequestMapping("/FLflyKnow") + public ModelAndView flyKnow(String id, HttpSession session) { + try { + // 开放指定文档的阅读和编辑权限 + farmDocOperateRightImpl.flyDoc(id, getCurrentUser(session)); + // 重定向到文档查看页面 + return ViewMode.getInstance().returnRedirectUrl("/webdoc/view/Pub" + id + ".html"); + } catch (Exception e) { + // 若出现异常,设置错误信息并返回错误页面的模型视图 + return ViewMode.getInstance().setError(e.toString()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + } + + /** + * 删除知识 + * + * @param id 文档 ID + * @param session HTTP 会话 + * @return 模型视图对象 + */ + // 定义请求映射,路径为 /FLDelKnow + @RequestMapping("/FLDelKnow") + public ModelAndView delCommit(String id, HttpSession session) { + try { + // 根据文档 ID 和当前用户删除文档 + DocEntire doc = farmDocManagerImpl.deleteDoc(id, getCurrentUser(session)); + // 判断文档的领域类型是否为 5(资源文件) + if (doc.getDoc().getDomtype().equals("5")) { + // 若为资源文件,遍历文档的所有附件 + for (FarmDocfile file : doc.getFiles()) { + // 删除附件的 Lucene 索引 + farmFileIndexManagerImpl.delFileLucenneIndex(file.getId(), doc); + } + } + // 设置删除成功的消息,并返回消息提示页面的模型视图 + return ViewMode.getInstance().putAttr("MESSAGE", "删除成功!") + .returnModelAndView(ThemesUtil.getThemePath() + "/message"); + } catch (Exception e) { + // 若出现异常,记录错误信息,设置错误信息并返回错误页面的模型视图 + log.error(e.getMessage()); + return ViewMode.getInstance().setError(e.toString()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + } + + /** + * 删除图片 + * + * @param imgid 图片 ID + * @return 包含操作结果的 Map 对象 + */ + // 定义请求映射,路径为 /delImg + @RequestMapping("/delImg") + public Map delImg(String imgid) { + try { + // 根据图片 ID 删除图片 + farmDocManagerImpl.delImg(imgid); + // 返回操作结果 + return ViewMode.getInstance().returnObjMode(); + } catch (Exception e) { + // 若出现异常,设置错误信息并返回操作结果 + return ViewMode.getInstance().setError(e.toString()).returnObjMode(); + } + } + + /** + * 对文档进行点赞 + * + * @param id 文档 ID + * @param session HTTP 会话 + * @param request HTTP 请求 + * @return 包含文档运行信息的 Map 对象 + */ + // 定义请求映射,路径为 /PubPraiseYes + @RequestMapping("/PubPraiseYes") + // 将返回值直接作为响应体返回 + @ResponseBody + public Map praiseYes(String id, HttpSession session, HttpServletRequest request) { + try { + // 判断当前用户是否存在 + if (getCurrentUser(session) == null) { + // 若用户未登录,使用用户 IP 地址对文档进行点赞 + farmDocRunInfoImpl.praiseDoc(id, request.getRemoteAddr()); + } else { + // 若用户已登录,使用用户信息和 IP 地址对文档进行点赞 + farmDocRunInfoImpl.praiseDoc(id, getCurrentUser(session), request.getRemoteAddr()); + } + // 加载文档的运行信息 + FarmDocruninfo runinfo = farmDocRunInfoImpl.loadRunInfo(id); + // 将文档运行信息放入 Map 对象并返回 + return ViewMode.getInstance().putAttr("runinfo", runinfo).returnObjMode(); + } catch (Exception e) { + // 若出现异常,打印异常堆栈信息,设置错误信息并返回 + e.printStackTrace(); + return ViewMode.getInstance().setError(e.toString()).returnObjMode(); + } + } + + /** + * 对文档进行差评 + * + * @param id 文档 ID + * @param session HTTP 会话 + * @param request HTTP 请求 + * @return 包含文档运行信息的 Map 对象 + */ + // 定义请求映射,路径为 /PubPraiseNo + @RequestMapping("/PubPraiseNo") + // 将返回值直接作为响应体返回 + @ResponseBody + public Map praiseNo(String id, HttpSession session, HttpServletRequest request) { + try { + // 判断当前用户是否存在 + if (getCurrentUser(session) == null) { + // 若用户未登录,使用用户 IP 地址对文档进行差评 + farmDocRunInfoImpl.criticalDoc(id, getCurrentIp(request)); + } else { + // 若用户已登录,使用用户信息和 IP 地址对文档进行差评 + farmDocRunInfoImpl.criticalDoc(id, getCurrentUser(session), getCurrentIp(request)); + } + // 加载文档的运行信息 + FarmDocruninfo runinfo = farmDocRunInfoImpl.loadRunInfo(id); + // 将文档运行信息放入 Map 对象并返回 + return ViewMode.getInstance().putAttr("runinfo", runinfo).returnObjMode(); + } catch (Exception e) { + // 若出现异常,打印异常堆栈信息,设置错误信息并返回 + e.printStackTrace(); + return ViewMode.getInstance().setError(e.toString()).returnObjMode(); + } + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/wcp/controller/DocGroupController.java b/src/wcp-web/src/main/java/com/farm/wcp/controller/DocGroupController.java new file mode 100644 index 0000000..00fb27a --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/controller/DocGroupController.java @@ -0,0 +1,616 @@ +// 包声明,指定该类所属的包路径 + package com.farm.wcp.controller; + +// 导入 Map 接口,用于处理键值对集合 +import java.util.Map; + +// 导入 Resource 注解,用于依赖注入 +import javax.annotation.Resource; +// 导入 HttpServletRequest 类,用于处理 HTTP 请求 +import javax.servlet.http.HttpServletRequest; +// 导入 HttpSession 类,用于管理用户会话 +import javax.servlet.http.HttpSession; + +// 导入 Controller 注解,将该类标记为 Spring MVC 的控制器 +import org.springframework.stereotype.Controller; +// 导入 RequestMapping 注解,用于映射请求路径 +import org.springframework.web.bind.annotation.RequestMapping; +// 导入 RequestMethod 枚举,用于指定请求方法 +import org.springframework.web.bind.annotation.RequestMethod; +// 导入 ResponseBody 注解,用于将方法的返回值直接写入响应体 +import org.springframework.web.bind.annotation.ResponseBody; +// 导入 ModelAndView 类,用于封装模型数据和视图信息 +import org.springframework.web.servlet.ModelAndView; + +// 导入 ViewMode 类,用于处理视图模式相关操作 +import com.farm.core.page.ViewMode; +// 导入 DataResult 类,用于处理数据库查询结果 +import com.farm.core.sql.result.DataResult; +// 导入 FarmDocgroup 类,用于表示文档小组实体 +import com.farm.doc.domain.FarmDocgroup; +// 导入 DocEntire 类,用于表示完整的文档实体 +import com.farm.doc.domain.ex.DocEntire; +// 导入 GroupEntire 类,用于表示完整的小组实体 +import com.farm.doc.domain.ex.GroupEntire; +// 导入 CanNoReadException 异常类,用于处理无权限读取文档的异常情况 +import com.farm.doc.exception.CanNoReadException; +// 导入 DocNoExistException 异常类,用于处理文档不存在的异常情况 +import com.farm.doc.exception.DocNoExistException; +// 导入 NoGroupAuthForLicenceException 异常类,用于处理无小组授权许可的异常情况 +import com.farm.doc.exception.NoGroupAuthForLicenceException; +// 导入 FarmDocManagerInter 接口,用于文档管理操作 +import com.farm.doc.server.FarmDocManagerInter; +// 导入 FarmDocOperateRightInter 接口,用于文档操作权限管理 +import com.farm.doc.server.FarmDocOperateRightInter; +// 导入 FarmDocRunInfoInter 接口,用于文档运行信息管理 +import com.farm.doc.server.FarmDocRunInfoInter; +// 导入 FarmDocgroupManagerInter 接口,用于文档小组管理操作 +import com.farm.doc.server.FarmDocgroupManagerInter; +// 导入 SearchType 枚举,用于指定搜索类型 +import com.farm.doc.server.FarmDocgroupManagerInter.SearchType; +// 导入 FarmDocmessageManagerInter 接口,用于文档消息管理 +import com.farm.doc.server.FarmDocmessageManagerInter; +// 导入 FarmFileManagerInter 接口,用于文件管理操作 +import com.farm.doc.server.FarmFileManagerInter; +// 导入 UsermessageServiceInter 接口,用于用户消息服务 +import com.farm.doc.server.UsermessageServiceInter; +// 导入 KnowServiceInter 接口,用于知识服务相关操作 +import com.farm.wcp.know.service.KnowServiceInter; +// 导入 ThemesUtil 类,用于处理主题相关的工具方法 +import com.farm.wcp.util.ThemesUtil; +// 导入 WebUtils 类,可能包含一些通用的 Web 工具方法 +import com.farm.web.WebUtils; + +/** + * 工作小组控制器类 + * + * @author wangdong + */ +// 定义请求映射的根路径为 /webgroup +@RequestMapping("/webgroup") +// 将该类标记为 Spring MVC 的控制器 +@Controller +// 继承 WebUtils 类,以获取其中的工具方法 +public class DocGroupController extends WebUtils { + // 注入 FarmDocgroupManagerInter 接口的实现类实例,用于文档小组管理 + @Resource + private FarmDocgroupManagerInter farmDocgroupManagerImpl; + // 注入 FarmFileManagerInter 接口的实现类实例,用于文件管理 + @Resource + private FarmFileManagerInter farmFileManagerImpl; + // 注入 FarmDocManagerInter 接口的实现类实例,用于文档管理 + @Resource + private FarmDocManagerInter farmDocManagerImpl; + // 注入 FarmDocRunInfoInter 接口的实现类实例,用于文档运行信息管理 + @Resource + private FarmDocRunInfoInter farmDocRunInfoImpl; + // 注入 KnowServiceInter 接口的实现类实例,用于知识服务 + @Resource + private KnowServiceInter KnowServiceImpl; + // 注入 FarmDocmessageManagerInter 接口的实现类实例,用于文档消息管理 + @Resource + private FarmDocmessageManagerInter farmDocmessageManagerImpl; + // 注入 FarmDocOperateRightInter 接口的实现类实例,用于文档操作权限管理 + @Resource + private FarmDocOperateRightInter farmDocOperateRightImpl; + // 注入 UsermessageServiceInter 接口的实现类实例,用于用户消息服务 + @Resource + private UsermessageServiceInter usermessageServiceImpl; + + /** + * 进入创建小组页面 + * + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return ModelAndView 对象,包含视图信息 + */ + // 映射 /add 请求路径,处理 GET 请求 + @RequestMapping("/add") + public ModelAndView add(HttpSession session, HttpServletRequest request) { + // 返回创建小组页面的视图模型,视图路径通过 ThemesUtil 获取主题路径后拼接 + return ViewMode.getInstance().returnModelAndView(ThemesUtil.getThemePath() + "/docgroup/editGroup"); + } + + /** + * 进入修改小组页面 + * + * @param groupid 小组 ID + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return ModelAndView 对象,包含小组信息和视图信息 + */ + // 映射 /edit 请求路径,处理 GET 请求 + @RequestMapping("/edit") + public ModelAndView edit(String groupid, HttpSession session, HttpServletRequest request) { + // 根据小组 ID 获取小组的完整信息 + GroupEntire group = farmDocgroupManagerImpl.getFarmDocgroup(groupid); + // 将小组信息放入视图模型,并返回修改小组页面的视图模型 + return ViewMode.getInstance().putAttr("group", group) + .returnModelAndView(ThemesUtil.getThemePath() + "/docgroup/editGroup"); + } + + /** + * 进入小组成员管理页面 + * + * @param page 页码,默认为 1 + * @param groupid 小组 ID + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return ModelAndView 对象,包含成员申请信息、小组信息、成员信息和视图信息 + */ + // 映射 /userMag 请求路径,处理 GET 请求 + @RequestMapping("/userMag") + public ModelAndView userEdit(Integer page, String groupid, HttpSession session, HttpServletRequest request) { + // 如果页码为空,设置为 1 + if (page == null) { + page = 1; + } + // 根据小组 ID 获取小组的完整信息 + GroupEntire group = farmDocgroupManagerImpl.getFarmDocgroup(groupid); + // 获取当前成员申请信息,筛选条件为已申请(yes),未处理(none),非成员(none) + DataResult result = farmDocgroupManagerImpl.getGroupUser(groupid, SearchType.yes, SearchType.none, + SearchType.none, 1, 10); + // 获取成员信息,筛选条件为非申请(no),未处理(none),非成员(none) + DataResult users = farmDocgroupManagerImpl.getGroupUser(groupid, SearchType.no, SearchType.none, + SearchType.none, page, 10); + // 将成员申请信息、小组信息、成员信息和小组 ID 放入视图模型,并返回小组成员管理页面的视图模型 + return ViewMode.getInstance().putAttr("applys", result).putAttr("group", group).putAttr("users", users) + .putAttr("groupid", groupid) + .returnModelAndView(ThemesUtil.getThemePath() + "/docgroup/groupAdminConsole"); + } + + /** + * 添加小组 + * + * @param group 小组实体对象 + * @param session HTTP 会话对象 + * @return ModelAndView 对象,根据操作结果重定向或返回错误页面 + */ + // 映射 /addCommit 请求路径,处理 POST 请求(默认) + @RequestMapping("/addCommit") + public ModelAndView addCommit(FarmDocgroup group, HttpSession session) { + try { + // 如果小组存在且小组 ID 不为空但长度为 0,将小组 ID 设置为 null + if (group != null && group.getId() != null && group.getId().trim().length() <= 0) { + group.setId(null); + } + // 创建小组,传入小组名称、标签、图片、是否需要审核、小组备注和当前用户信息 + group = farmDocgroupManagerImpl.creatDocGroup(group.getGroupname(), group.getGrouptag(), + group.getGroupimg(), group.getJoincheck().equals("1")? true : false, group.getGroupnote(), + getCurrentUser(session)); + // 将小组 ID 放入视图模型,并重定向到小组首页 + return ViewMode.getInstance().putAttr("id", group.getId()).returnRedirectUrl("/webgroup/PubHome.html"); + } catch (NoGroupAuthForLicenceException e) { + // 如果捕获到无小组授权许可异常,重定向到小组首页 + return ViewMode.getInstance().returnRedirectUrl("/webgroup/PubHome.html"); + } catch (Exception e) { + // 如果捕获到其他异常,重定向到小组首页 + return ViewMode.getInstance().returnRedirectUrl("/webgroup/PubHome.html"); + } + } + + /** + * 修改小组 + * + * @param group 小组实体对象 + * @param session HTTP 会话对象 + * @return ModelAndView 对象,根据操作结果重定向或返回错误页面 + */ + // 映射 /editCommit 请求路径,处理 POST 请求(默认) + @RequestMapping("/editCommit") + public ModelAndView editCommit(FarmDocgroup group, HttpSession session) { + try { + // 设置小组状态为 "1" + group.setPstate("1"); + // 编辑小组信息,传入小组实体和当前用户信息 + farmDocgroupManagerImpl.editFarmDocgroupEntity(group, getCurrentUser(session)); + // 重定向到小组展示页面,带上小组 ID + return ViewMode.getInstance().returnRedirectUrl("/webgroup/Pubshow.do?groupid=" + group.getId()); + } catch (Exception e) { + // 如果捕获到异常,打印异常堆栈信息,并重定向到小组展示页面 + e.printStackTrace(); + return ViewMode.getInstance().returnRedirectUrl("/webgroup/Pubshow.do?groupid=" + group.getId()); + } + } + + /** + * 编辑小组首页 + * + * @param groupId 小组 ID + * @param docid 文档 ID + * @param text 文档文本内容 + * @param editNote 编辑备注 + * @param session HTTP 会话对象 + * @return ModelAndView 对象,根据操作结果重定向或返回错误页面 + */ + // 映射 /homeeditCommit 请求路径,处理 POST 请求(默认) + @RequestMapping("/homeeditCommit") + public ModelAndView homeeditCommit(String groupId, String docid, String text, String editNote, + HttpSession session) { + try { + // 根据文档 ID 获取文档的完整信息(此处 @SuppressWarnings 抑制了关于方法过时的警告) + @SuppressWarnings("deprecation") + DocEntire doc = farmDocManagerImpl.getDoc(docid); + // 设置文档的文本内容和当前用户信息 + doc.setTexts(text, getCurrentUser(session)); + // 编辑文档,传入文档实体、编辑备注和当前用户信息 + farmDocManagerImpl.editDoc(doc, editNote, getCurrentUser(session)); + // 重定向到小组展示页面,带上小组 ID + return ViewMode.getInstance().returnRedirectUrl("/webgroup/Pubshow.do?groupid=" + groupId); + } catch (Exception e) { + // 如果捕获到异常,打印异常堆栈信息,并重定向到小组展示页面 + e.printStackTrace(); + return ViewMode.getInstance().returnRedirectUrl("/webgroup/Pubshow.do?groupid=" + groupId); + } + } + + /** + * 进入编辑小组首页 + * + * @param groupid 小组 ID + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return ModelAndView 对象,包含小组信息、文档信息和视图信息 + */ + // 映射 /homeedit 请求路径,处理 GET 请求 + @RequestMapping("/homeedit") + public ModelAndView homeedit(String groupid, HttpSession session, HttpServletRequest request) { + // 获取视图模式实例 + ViewMode mode = ViewMode.getInstance(); + try { + // 根据小组 ID 获取小组实体信息 + FarmDocgroup group = farmDocgroupManagerImpl.getFarmDocgroupEntity(groupid); + // 获取小组首页文档 ID + String homedocid = group.getHomedocid(); + // 根据首页文档 ID 获取文档的完整信息(此处 @SuppressWarnings 抑制了关于方法过时的警告) + @SuppressWarnings("deprecation") + DocEntire doc = farmDocManagerImpl.getDoc(homedocid); + // 将小组信息和文档信息放入视图模型 + mode.putAttr("group", group).putAttr("doc", doc); + } catch (Exception e) { + // 如果捕获到异常,设置错误信息并返回错误页面的视图模型 + return ViewMode.getInstance().setError(e.toString()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + // 返回编辑小组首页的视图模型 + return mode.returnModelAndView(ThemesUtil.getThemePath() + "/docgroup/editHomePage"); + } + + /** + * 进入小组页面 + * + * @param typeid 类型 ID(未使用) + * @param pagenum 页码,默认为 1 + * @param groupid 小组 ID + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return ModelAndView 对象,包含文档数量、首页文档信息、小组 ID、小组信息和视图信息 + */ + // 映射 /Pubshow 请求路径,处理 GET 请求 + @RequestMapping("/Pubshow") + public ModelAndView show(String typeid, Integer pagenum, String groupid, HttpSession session, + HttpServletRequest request) { + // 获取视图模式实例 + ViewMode mode = ViewMode.getInstance(); + // 根据小组 ID 获取小组的完整信息 + GroupEntire docgroup = farmDocgroupManagerImpl.getFarmDocgroup(groupid); + // 初始化文档实体对象 + DocEntire doc = null; + try { + // 根据小组首页文档 ID 和当前用户信息获取文档的完整信息 + doc = farmDocManagerImpl.getDoc(docgroup.getHomedocid(), getCurrentUser(session)); + } catch (CanNoReadException | DocNoExistException e) { + // 如果捕获到无权限读取或文档不存在异常,设置错误信息并返回错误页面的视图模型 + return ViewMode.getInstance().setError("没有权限,或不存在") + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + // 如果当前用户存在 + if (getCurrentUser(session) != null) { + // 获取当前用户在小组中的权限信息,并放入视图模型 + mode.putAttr("usergroup", + farmDocgroupManagerImpl.getFarmDocgroupUser(docgroup.getId(), getCurrentUser(session).getId())); + } + // 获取小组内文档数量 + int docnum = farmDocgroupManagerImpl.getGroupDocNum(groupid); + // 获取小组最新知识,传入当前用户、小组 ID、数量和页码 + mode.putAttr("docs", + farmDocgroupManagerImpl.getNewGroupDoc(getCurrentUser(session) == null? null : getCurrentUser(session), + groupid, 10, pagenum == null? 1 : pagenum)); + // 将文档数量、首页文档信息、小组 ID、小组信息放入视图模型,并返回小组页面的视图模型 + return mode.putAttr("docnum", docnum).putAttr("home", doc).putAttr("groupid", groupid) + .putAttr("docgroup", docgroup).returnModelAndView(ThemesUtil.getThemePath() + "/docgroup/groupSite"); + } + + /** + * 小组栏目首页 + * + * @param pagenum 页码,默认为 1 + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return ModelAndView 对象,包含小组信息和视图信息 + * @throws Exception 可能抛出的异常 + */ + // 映射 /PubHome 请求路径,处理 GET 请求 + @RequestMapping(value = "/PubHome", method = RequestMethod.GET) + public ModelAndView showDoc(Integer pagenum, HttpSession session, HttpServletRequest request) throws Exception { + // 如果页码为空,设置为 1 + if (pagenum == null) { + pagenum = 1; + } + // 获取最热的小组信息,传入数量和页码 + DataResult groups = farmDocgroupManagerImpl.getGroups(12, pagenum); + // 将小组信息放入视图模型,并返回小组栏目 +// 将最热小组信息放入视图模式的属性中,并返回小组栏目首页的视图模型 + return ViewMode.getInstance().putAttr("groups", groups) + .returnModelAndView(ThemesUtil.getThemePath() + "/docgroup/groupHome"); + } + + /** + * 取消用户管理员权限 + * + * @param groupUserId 小组用户 ID + * @param session HTTP 会话 + * @return 包含操作结果的 Map 对象 + */ + // 定义请求映射,处理 /wipeAdmin 路径的请求 + @RequestMapping("/wipeAdmin") + // 将方法返回值直接作为响应体返回 + @ResponseBody + public Map groupWipeAdmin(String groupUserId, HttpSession session) { + try { + // 调用服务方法,取消指定用户在小组中的管理员权限 + farmDocgroupManagerImpl.wipeAdminFromGroup(groupUserId, getCurrentUser(session)); + } catch (Exception e) { + // 若出现异常,设置错误信息并返回操作结果 + return ViewMode.getInstance().setError(e.getMessage()).returnObjMode(); + } + // 若操作成功,返回操作结果 + return ViewMode.getInstance().returnObjMode(); + } + + /** + * 设置为小组管理员 + * + * @param groupUserId 小组用户 ID + * @param session HTTP 会话 + * @return 包含操作结果的 Map 对象 + */ + // 定义请求映射,处理 /groupSetAdmin 路径的请求 + @RequestMapping("/groupSetAdmin") + // 将方法返回值直接作为响应体返回 + @ResponseBody + public Map groupSetAdmin(String groupUserId, HttpSession session) { + try { + // 调用服务方法,将指定用户设置为小组管理员 + farmDocgroupManagerImpl.setAdminForGroup(groupUserId, getCurrentUser(session)); + } catch (Exception e) { + // 若出现异常,设置错误信息并返回操作结果 + return ViewMode.getInstance().setError(e.getMessage()).returnObjMode(); + } + // 若操作成功,返回操作结果 + return ViewMode.getInstance().returnObjMode(); + } + + /** + * 去除小组编辑权限 + * + * @param groupUserId 小组用户 ID + * @param session HTTP 会话 + * @return 包含操作结果的 Map 对象 + */ + // 定义请求映射,处理 /groupWipeEditor 路径的请求 + @RequestMapping("/groupWipeEditor") + // 将方法返回值直接作为响应体返回 + @ResponseBody + public Map groupWipeEditor(String groupUserId, HttpSession session) { + try { + // 调用服务方法,去除指定用户在小组中的编辑权限 + farmDocgroupManagerImpl.wipeEditorForGroup(groupUserId, getCurrentUser(session)); + } catch (Exception e) { + // 若出现异常,设置错误信息并返回操作结果 + return ViewMode.getInstance().setError(e.getMessage()).returnObjMode(); + } + // 若操作成功,返回操作结果 + return ViewMode.getInstance().returnObjMode(); + } + + /** + * 设置小组编辑权限 + * + * @param groupUserId 小组用户 ID + * @param session HTTP 会话 + * @return 包含操作结果的 Map 对象 + */ + // 定义请求映射,处理 /groupSetEditor 路径的请求 + @RequestMapping("/groupSetEditor") + // 将方法返回值直接作为响应体返回 + @ResponseBody + public Map groupSetEditor(String groupUserId, HttpSession session) { + try { + // 调用服务方法,为指定用户设置小组编辑权限 + farmDocgroupManagerImpl.setEditorForGroup(groupUserId, getCurrentUser(session)); + } catch (Exception e) { + // 若出现异常,设置错误信息并返回操作结果 + return ViewMode.getInstance().setError(e.getMessage()).returnObjMode(); + } + // 若操作成功,返回操作结果 + return ViewMode.getInstance().returnObjMode(); + } + + /** + * 将用户退出小组 + * + * @param groupUserId 小组用户 ID + * @param session HTTP 会话 + * @return 包含操作结果的 Map 对象 + */ + // 定义请求映射,处理 /groupQuit 路径的请求 + @RequestMapping("/groupQuit") + // 将方法返回值直接作为响应体返回 + @ResponseBody + public Map groupQuit(String groupUserId, HttpSession session) { + try { + // 调用服务方法,将指定用户从小组中移除 + farmDocgroupManagerImpl.leaveGroup(groupUserId, getCurrentUser(session)); + } catch (Exception e) { + // 若出现异常,设置错误信息并返回操作结果 + return ViewMode.getInstance().setError(e.getMessage()).returnObjMode(); + } + // 若操作成功,返回操作结果 + return ViewMode.getInstance().returnObjMode(); + } + + /** + * 同意加入小组 + * + * @param groupUserId 小组用户 ID + * @param session HTTP 会话 + * @return 包含操作结果的 Map 对象 + */ + // 定义请求映射,处理 /agreeApply 路径的请求 + @RequestMapping("/agreeApply") + // 将方法返回值直接作为响应体返回 + @ResponseBody + public Map agreeApply(String groupUserId, HttpSession session) { + try { + // 调用服务方法,同意指定用户加入小组的申请 + farmDocgroupManagerImpl.agreeJoinApply(groupUserId, getCurrentUser(session)); + } catch (Exception e) { + // 若出现异常,设置错误信息并返回操作结果 + return ViewMode.getInstance().setError(e.getMessage()).returnObjMode(); + } + // 若操作成功,返回操作结果 + return ViewMode.getInstance().returnObjMode(); + } + + /** + * 拒绝加入小组 + * + * @param groupUserId 小组用户 ID + * @param session HTTP 会话 + * @return 包含操作结果的 Map 对象 + */ + // 定义请求映射,处理 /refuseApply 路径的请求 + @RequestMapping("/refuseApply") + // 将方法返回值直接作为响应体返回 + @ResponseBody + public Map refuseApply(String groupUserId, HttpSession session) { + try { + // 调用服务方法,拒绝指定用户加入小组的申请 + farmDocgroupManagerImpl.refuseJoinApply(groupUserId, getCurrentUser(session)); + } catch (Exception e) { + // 若出现异常,设置错误信息并返回操作结果 + return ViewMode.getInstance().setError(e.getMessage()).returnObjMode(); + } + // 若操作成功,返回操作结果 + return ViewMode.getInstance().returnObjMode(); + } + + /** + * 是否有小组管理员存在,通过小组管理员数量判断 + * + * @param groupId 小组 ID + * @return 包含管理员数量的 Map 对象 + */ + // 定义请求映射,处理 /haveAdministratorIs 路径的请求 + @RequestMapping("/haveAdministratorIs") + // 将方法返回值直接作为响应体返回 + @ResponseBody + public Map haveAdministratorIs(String groupId) { + // 获取指定小组的所有管理员,并统计数量 + int adminNum = farmDocgroupManagerImpl.getAllAdministratorByGroup(groupId).size(); + // 将管理员数量放入视图模式的属性中,并返回操作结果 + return ViewMode.getInstance().putAttr("adminNum", adminNum).returnObjMode(); + } + + /** + * 退出小组 + * + * @param groupId 小组 ID + * @param session HTTP 会话 + * @return 重定向到小组展示页面的模型视图 + */ + // 定义请求映射,处理 /leaveGroup 路径的请求 + @RequestMapping("/leaveGroup") + public ModelAndView leaveGroup(String groupId, HttpSession session) { + // 调用服务方法,将当前用户从指定小组中移除 + farmDocgroupManagerImpl.leaveGroup(groupId, getCurrentUser(session).getId()); + // 重定向到小组展示页面 + return ViewMode.getInstance().returnRedirectUrl("/webgroup/Pubshow.do?groupid=" + groupId); + } + + /** + * 加入小组 + * + * @param groupId 小组 ID + * @param session HTTP 会话 + * @param request HTTP 请求 + * @return 重定向到小组展示页面或错误页面的模型视图 + * @throws Exception 可能抛出的异常 + */ + // 定义请求映射,处理 /join 路径的请求 + @RequestMapping("/join") + public ModelAndView joinform(String groupId, HttpSession session, HttpServletRequest request) throws Exception { + // 判断当前用户是否已经加入该小组 + if (farmDocgroupManagerImpl.isJoinGroupByUser(groupId, getCurrentUser(session).getId())) { + // 判断当前用户的加入申请是否正在审核中 + if (farmDocgroupManagerImpl.isAuditing(groupId, getCurrentUser(session).getId())) { + // 若正在审核中,设置错误信息并返回错误页面的模型视图 + return ViewMode.getInstance().setError("正在审核中") + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } else { + // 若已加入且审核通过,进入小组(此处代码未做额外处理) + } + } else { + // 若未加入小组 + // 判断该小组加入是否需要审核 + if (farmDocgroupManagerImpl.isJoinCheck(groupId)) { + // 若需要审核,将小组信息放入视图模式的属性中,并返回加入申请页面的模型视图 + return ViewMode.getInstance().putAttr("group", farmDocgroupManagerImpl.getFarmDocgroupEntity(groupId)) + .returnModelAndView(ThemesUtil.getThemePath() + "/docgroup/joinCheckForm"); + } else { + // 若不需要审核,直接调用服务方法申请加入小组 + farmDocgroupManagerImpl.applyGroup(groupId, getCurrentUser(session).getId(), "申请加入", + getCurrentUser(session)); + } + } + // 将小组 ID 放入视图模式的属性中,并重定向到小组展示页面 + return ViewMode.getInstance().putAttr("groupid", groupId).returnRedirectUrl("/webgroup/Pubshow.do"); + } + + /** + * 提交小组申请 + * + * @param groupId 小组 ID + * @param message 申请消息 + * @param session HTTP 会话 + * @param request HTTP 请求 + * @return 重定向到小组展示页面、错误页面或消息提示页面的模型视图 + * @throws Exception 可能抛出的异常 + */ + // 定义请求映射,处理 /joincommit 路径的请求 + @RequestMapping("/joincommit") + public ModelAndView joincommit(String groupId, String message, HttpSession session, HttpServletRequest request) + throws Exception { + // 判断当前用户是否已经加入该小组 + if (farmDocgroupManagerImpl.isJoinGroupByUser(groupId, getCurrentUser(session).getId())) { + // 判断当前用户的加入申请是否正在审核中 + if (farmDocgroupManagerImpl.isAuditing(groupId, getCurrentUser(session).getId())) { + // 若正在审核中,设置错误信息并返回错误页面的模型视图 + return ViewMode.getInstance().setError("正在审核中") + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } else { + // 若已加入且审核通过,将小组 ID 放入视图模式的属性中,并重定向到小组展示页面 + return ViewMode.getInstance().putAttr("groupid", groupId).returnRedirectUrl("/webgroup/Pubshow.do"); + } + } else { + // 若未加入小组,直接调用服务方法申请加入小组 + farmDocgroupManagerImpl.applyGroup(groupId, getCurrentUser(session).getId(), message, + getCurrentUser(session)); + // 设置申请提交成功的提示信息,并返回消息提示页面的模型视图 + return ViewMode.getInstance().setError("已经提交加入请求,请等待管理员审核!") + .returnModelAndView(ThemesUtil.getThemePath() + "/message"); + } + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/wcp/controller/DocLuceneController.java b/src/wcp-web/src/main/java/com/farm/wcp/controller/DocLuceneController.java new file mode 100644 index 0000000..44eab96 --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/controller/DocLuceneController.java @@ -0,0 +1,234 @@ +// 声明包路径 +package com.farm.wcp.controller; + +// 导入 URLDecoder 类,用于解码 URL 编码的字符串 +import java.net.URLDecoder; +// 导入 List 接口,用于处理有序集合 +import java.util.List; +// 导入 Map 接口,用于处理键值对集合 +import java.util.Map; + +// 导入 Resource 注解,用于依赖注入 +import javax.annotation.Resource; +// 导入 HttpServletRequest 类,用于处理 HTTP 请求 +import javax.servlet.http.HttpServletRequest; +// 导入 HttpSession 类,用于管理用户会话 +import javax.servlet.http.HttpSession; + +// 导入 Controller 注解,将该类标记为 Spring MVC 的控制器 +import org.springframework.stereotype.Controller; +// 导入 RequestMapping 注解,用于映射请求路径 +import org.springframework.web.bind.annotation.RequestMapping; +// 导入 RequestMethod 枚举,用于指定请求方法 +import org.springframework.web.bind.annotation.RequestMethod; +// 导入 ResponseBody 注解,用于将方法的返回值直接写入响应体 +import org.springframework.web.bind.annotation.ResponseBody; +// 导入 ModelAndView 类,用于封装模型数据和视图信息 +import org.springframework.web.servlet.ModelAndView; + +// 导入 UserServiceInter 接口,用于用户服务相关操作 +import com.farm.authority.service.UserServiceInter; +// 导入 ViewMode 类,用于处理视图模式相关操作 +import com.farm.core.page.ViewMode; +// 导入 DataResult 类,用于处理数据库查询结果 +import com.farm.core.sql.result.DataResult; +// 导入 DocBrief 类,用于表示文档的简要信息 +import com.farm.doc.domain.ex.DocBrief; +// 导入 TypeBrief 类,用于表示类型的简要信息 +import com.farm.doc.domain.ex.TypeBrief; +// 导入 FarmDocIndexInter 接口,用于文档索引相关操作 +import com.farm.doc.server.FarmDocIndexInter; +// 导入 FarmDocManagerInter 接口,用于文档管理相关操作 +import com.farm.doc.server.FarmDocManagerInter; +// 导入 FarmDocOperateRightInter 接口,用于文档操作权限相关操作 +import com.farm.doc.server.FarmDocOperateRightInter; +// 导入 FarmDocRunInfoInter 接口,用于文档运行信息相关操作 +import com.farm.doc.server.FarmDocRunInfoInter; +// 导入 FarmDocTypeInter 接口,用于文档类型相关操作 +import com.farm.doc.server.FarmDocTypeInter; +// 导入 FarmDocgroupManagerInter 接口,用于文档小组管理相关操作 +import com.farm.doc.server.FarmDocgroupManagerInter; +// 导入 FarmDocmessageManagerInter 接口,用于文档消息管理相关操作 +import com.farm.doc.server.FarmDocmessageManagerInter; +// 导入 FarmFileManagerInter 接口,用于文件管理相关操作 +import com.farm.doc.server.FarmFileManagerInter; +// 导入 FarmParameterService 类,用于获取系统参数 +import com.farm.parameter.FarmParameterService; +// 导入 WebHotCase 类,用于获取热门案例相关信息 +import com.farm.util.web.WebHotCase; +// 导入 KnowServiceInter 接口,用于知识服务相关操作 +import com.farm.wcp.know.service.KnowServiceInter; +// 导入 ThemesUtil 类,用于处理主题相关的工具方法 +import com.farm.wcp.util.ThemesUtil; +// 导入 WebUtils 类,可能包含一些通用的 Web 工具方法 +import com.farm.web.WebUtils; + +/** + * 检索控制器类 + * + * @author wangdong + */ +// 定义请求映射的根路径为 /websearch +@RequestMapping("/websearch") +// 将该类标记为 Spring MVC 的控制器 +@Controller +// 继承 WebUtils 类,以获取其中的工具方法 +public class DocLuceneController extends WebUtils { + // 注入 FarmDocgroupManagerInter 接口的实现类实例,用于文档小组管理 + @Resource + private FarmDocgroupManagerInter farmDocgroupManagerImpl; + // 注入 FarmFileManagerInter 接口的实现类实例,用于文件管理 + @Resource + private FarmFileManagerInter farmFileManagerImpl; + // 注入 FarmDocManagerInter 接口的实现类实例,用于文档管理 + @Resource + private FarmDocManagerInter farmDocManagerImpl; + // 注入 FarmDocRunInfoInter 接口的实现类实例,用于文档运行信息管理 + @Resource + private FarmDocRunInfoInter farmDocRunInfoImpl; + // 注入 KnowServiceInter 接口的实现类实例,用于知识服务 + @Resource + private KnowServiceInter KnowServiceImpl; + // 注入 FarmDocTypeInter 接口的实现类实例,用于文档类型管理 + @Resource + private FarmDocTypeInter farmDocTypeManagerImpl; + // 注入 FarmDocmessageManagerInter 接口的实现类实例,用于文档消息管理 + @Resource + private FarmDocmessageManagerInter farmDocmessageManagerImpl; + // 注入 FarmDocOperateRightInter 接口的实现类实例,用于文档操作权限管理 + @Resource + private FarmDocOperateRightInter farmDocOperateRightImpl; + // 注入 FarmDocIndexInter 接口的实现类实例,用于文档索引管理 + @Resource + private FarmDocIndexInter farmDocIndexManagerImpl; + // 注入 UserServiceInter 接口的实现类实例,用于用户服务 + @Resource + private UserServiceInter userServiceImpl; + + /** + * 检索首页 + * + * @param pagenum 页码 + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return ModelAndView 对象,包含视图信息 + * @throws Exception 可能抛出的异常 + */ + // 映射 /PubHome 请求路径,处理 GET 请求 + @RequestMapping(value = "/PubHome", method = RequestMethod.GET) + public ModelAndView show(Integer pagenum, HttpSession session, HttpServletRequest request) throws Exception { + // 获取视图模式实例 + ViewMode mode = ViewMode.getInstance(); + // 获取系统参数中配置的热门案例显示数量,并根据该数量获取热门案例列表 + List hotCase = WebHotCase.getCases( + Integer.valueOf(FarmParameterService.getInstance().getParameter("config.sys.webhotcase.show.num"))); + // 获取用户可见的文档类型简要信息,"NONE" 可能是某种筛选条件 + List typesons = farmDocTypeManagerImpl.getTypeInfos(getCurrentUser(session), "NONE"); + // 如果当前用户存在 + if (getCurrentUser(session) != null) { + // 根据当前用户 ID 获取用户所属的小组列表,数量为 100,页码为 1 + DataResult groups = farmDocgroupManagerImpl.getGroupsByUser(getCurrentUser(session).getId(), 100, 1); + // 将小组列表放入视图模型的属性中 + mode.putAttr("groups", groups.getResultList()); + } + // 获取最新的 6 条知识的简要信息 + List newdocs = farmDocRunInfoImpl.getNewKnowList(6); + // 获取前五条置顶文档的简要信息 + List topdocs = farmDocRunInfoImpl.getPubTopDoc(2); + // 获取 10 条热门文档的简要信息 + List hotdocs = farmDocRunInfoImpl.getPubHotDoc(10); + // 将文档类型信息、最新知识信息、置顶文档信息、热门文档信息、热门案例信息放入视图模型,并返回检索首页的视图模型 + return mode.putAttr("typesons", typesons).putAttr("docbriefs", newdocs).putAttr("topDocList", topdocs).putAttr("hotdocs", hotdocs) + .putAttr("hotCase", hotCase).returnModelAndView(ThemesUtil.getThemePath() + "/lucene/search"); + } + + /** + * 检索 + * + * @param word 检索词 + * @param pagenum 页码 + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return ModelAndView 对象,包含视图信息 + * @throws Exception 可能抛出的异常 + */ + // 映射 /PubDo 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping(value = "/PubDo") + public ModelAndView search(String word, Integer pagenum, HttpSession session, HttpServletRequest request) + throws Exception { + // 初始化用户 ID 为 null + String userid = null; + // 如果当前用户存在,获取用户 ID + if (getCurrentUser(session) != null) { + userid = getCurrentUser(session).getId(); + } + // 如果检索词为 null,设置为空字符串 + if (word == null) { + word = ""; + } + // 对检索词进行 URL 解码,使用 UTF-8 编码 + word = URLDecoder.decode(word, "utf-8"); + // 获取视图模式实例 + ViewMode mode = ViewMode.getInstance(); + // 获取系统参数中配置的热门案例显示数量,并根据该数量获取热门案例列表 + List hotCase = WebHotCase.getCases( + Integer.valueOf(FarmParameterService.getInstance().getParameter("config.sys.webhotcase.show.num"))); + // 如果检索词为空或空字符串 + if (word == null || word.isEmpty()) { + // 获取用户可见的文档类型简要信息,"NONE" 可能是某种筛选条件 + List typesons = farmDocTypeManagerImpl.getTypeInfos(getCurrentUser(session), "NONE"); + // 获取前五条置顶文档的简要信息 + List topdocs = farmDocRunInfoImpl.getPubTopDoc(2); + // 获取 10 条热门文档的简要信息 + List hotdocs = farmDocRunInfoImpl.getPubHotDoc(10); + // 设置错误信息为 "请输入检索词",并将置顶文档信息、热门案例信息、热门文档信息、文档类型信息放入视图模型,返回检索首页的视图模型 + return mode.setError("请输入检索词").putAttr("topDocList", topdocs).putAttr("hotCase", hotCase) + .putAttr("hotdocs", hotdocs).putAttr("typesons", typesons) + .returnModelAndView(ThemesUtil.getThemePath() + "/lucene/search"); + } + try { + // 获取用户可见的流行文档类型简要信息 + List types = farmDocTypeManagerImpl.getPopTypesForReadDoc(getCurrentUser(session)); + // 根据检索词、用户 ID 和页码进行文档检索 + DataResult result = farmDocIndexManagerImpl.search(word, userid, pagenum); + // 将检索结果、文档类型信息、热门案例信息、检索词放入视图模型,并返回检索结果页面的视图模型 + return mode.putAttr("result", result).putAttr("types", types).putAttr("hotCase", hotCase) + .putAttr("word", word).returnModelAndView(ThemesUtil.getThemePath() + "/lucene/searchResult"); + } catch (Exception e) { + // 如果捕获到异常,设置错误信息为异常信息,并返回错误页面的视图模型 + return mode.setError(e.toString()).returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + } + + /** + * 查看知识的关联知识 + * + * @param docid 文档 ID + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return 包含关联知识信息的 Map 对象 + * @throws Exception 可能抛出的异常 + */ + // 映射 /PubRelationDocs 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/PubRelationDocs") + // 将方法返回值直接作为响应体返回 + @ResponseBody + public Map relationDocs(String docid, HttpSession session, HttpServletRequest request) + throws Exception { + // 获取视图模式实例 + ViewMode page = ViewMode.getInstance(); + try { + // 根据文档 ID 获取 10 条关联文档的简要信息 + List relationdocs = farmDocIndexManagerImpl.getRelationDocs(docid, 10); + // 将关联文档信息放入视图模型的属性中 + page.putAttr("RELATIONDOCS", relationdocs); + } catch (Exception e) { + // 如果捕获到异常,设置错误信息为异常信息,并返回操作结果 + return ViewMode.getInstance().setError(e.getMessage()).returnObjMode(); + } + // 返回操作结果 + return page.returnObjMode(); + } +} + + diff --git a/src/wcp-web/src/main/java/com/farm/wcp/controller/DocMessageController.java b/src/wcp-web/src/main/java/com/farm/wcp/controller/DocMessageController.java new file mode 100644 index 0000000..26fd727 --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/controller/DocMessageController.java @@ -0,0 +1,238 @@ +// 声明包路径 +package com.farm.wcp.controller; + +// 导入 List 接口,用于处理有序集合 +import java.util.List; +// 导入 Map 接口,用于处理键值对集合 +import java.util.Map; + +// 导入 Resource 注解,用于依赖注入 +import javax.annotation.Resource; +// 导入 HttpServletRequest 类,用于处理 HTTP 请求 +import javax.servlet.http.HttpServletRequest; +// 导入 HttpSession 类,用于管理用户会话 +import javax.servlet.http.HttpSession; + +// 导入 Logger 类,用于日志记录 +import org.apache.log4j.Logger; +// 导入 Controller 注解,将该类标记为 Spring MVC 的控制器 +import org.springframework.stereotype.Controller; +// 导入 RequestMapping 注解,用于映射请求路径 +import org.springframework.web.bind.annotation.RequestMapping; +// 导入 ResponseBody 注解,用于将方法的返回值直接写入响应体 +import org.springframework.web.bind.annotation.ResponseBody; +// 导入 ModelAndView 类,用于封装模型数据和视图信息 +import org.springframework.web.servlet.ModelAndView; + +// 导入 ViewMode 类,用于处理视图模式相关操作 +import com.farm.core.page.ViewMode; +// 导入 DataResult 类,用于处理数据库查询结果 +import com.farm.core.sql.result.DataResult; +// 导入 FarmRfDoctypeDaoInter 接口,用于文档类型关联数据访问 +import com.farm.doc.dao.FarmRfDoctypeDaoInter; +// 导入 FarmDocmessage 类,用于表示文档消息实体 +import com.farm.doc.domain.FarmDocmessage; +// 导入 DocBrief 类,用于表示文档的简要信息 +import com.farm.doc.domain.ex.DocBrief; +// 导入 DocEntire 类,用于表示完整的文档实体 +import com.farm.doc.domain.ex.DocEntire; +// 导入 FarmDocIndexInter 接口,用于文档索引相关操作 +import com.farm.doc.server.FarmDocIndexInter; +// 导入 FarmDocManagerInter 接口,用于文档管理相关操作 +import com.farm.doc.server.FarmDocManagerInter; +// 导入 FarmDocOperateRightInter 接口,用于文档操作权限相关操作 +import com.farm.doc.server.FarmDocOperateRightInter; +// 导入 FarmDocRunInfoInter 接口,用于文档运行信息相关操作 +import com.farm.doc.server.FarmDocRunInfoInter; +// 导入 FarmDocgroupManagerInter 接口,用于文档小组管理相关操作 +import com.farm.doc.server.FarmDocgroupManagerInter; +// 导入 FarmDocmessageManagerInter 接口,用于文档消息管理相关操作 +import com.farm.doc.server.FarmDocmessageManagerInter; +// 导入 FarmFileIndexManagerInter 接口,用于文件索引管理相关操作 +import com.farm.doc.server.FarmFileIndexManagerInter; +// 导入 FarmFileManagerInter 接口,用于文件管理相关操作 +import com.farm.doc.server.FarmFileManagerInter; +// 导入 KnowServiceInter 接口,用于知识服务相关操作 +import com.farm.wcp.know.service.KnowServiceInter; +// 导入 ThemesUtil 类,用于处理主题相关的工具方法 +import com.farm.wcp.util.ThemesUtil; +// 导入 WebUtils 类,可能包含一些通用的 Web 工具方法 +import com.farm.web.WebUtils; + +// 定义请求映射的根路径为 /webdocmessage +@RequestMapping("/webdocmessage") +// 将该类标记为 Spring MVC 的控制器 +@Controller +// 继承 WebUtils 类,以获取其中的工具方法 +public class DocMessageController extends WebUtils { + // 注入 FarmDocgroupManagerInter 接口的实现类实例,用于文档小组管理 + @Resource + private FarmDocgroupManagerInter farmDocgroupManagerImpl; + // 注入 FarmFileManagerInter 接口的实现类实例,用于文件管理 + @Resource + private FarmFileManagerInter farmFileManagerImpl; + // 注入 FarmDocManagerInter 接口的实现类实例,用于文档管理 + @Resource + private FarmDocManagerInter farmDocManagerImpl; + // 注入 FarmDocRunInfoInter 接口的实现类实例,用于文档运行信息管理 + @Resource + private FarmDocRunInfoInter farmDocRunInfoImpl; + // 注入 KnowServiceInter 接口的实现类实例,用于知识服务 + @Resource + private KnowServiceInter KnowServiceImpl; + // 注入 FarmDocmessageManagerInter 接口的实现类实例,用于文档消息管理 + @Resource + private FarmDocmessageManagerInter farmDocmessageManagerImpl; + // 注入 FarmDocOperateRightInter 接口的实现类实例,用于文档操作权限管理 + @Resource + private FarmDocOperateRightInter farmDocOperateRightImpl; + // 注入 FarmDocIndexInter 接口的实现类实例,用于文档索引管理 + @Resource + private FarmDocIndexInter farmDocIndexManagerImpl; + // 注入 FarmRfDoctypeDaoInter 接口的实现类实例,用于文档类型关联数据访问 + @Resource + private FarmRfDoctypeDaoInter farmRfDoctypeDaoImpl; + // 注入 FarmFileIndexManagerInter 接口的实现类实例,用于文件索引管理 + @Resource + private FarmFileIndexManagerInter farmFileIndexManagerImpl; + + /** + * 显示文档消息页面 + * + * @param num 页码,默认为 1 + * @param docid 文档 ID + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return ModelAndView 对象,包含视图信息 + */ + // 映射 /Pubmsg 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/Pubmsg") + public ModelAndView docMessage(Integer num, String docid, HttpSession session, HttpServletRequest request) { + try { + // 获取 10 条热门文档的简要信息 + List hotdocs = farmDocRunInfoImpl.getPubHotDoc(10); + // 根据文档 ID 和当前用户获取完整的文档信息 + DocEntire doc = farmDocManagerImpl.getDoc(docid, getCurrentUser(session)); + // 如果页码为 null,设置为 1 + if (num == null) { + num = 1; + } + // 根据文档 ID、页码和每页数量(20)获取文档消息列表 + DataResult result = farmDocmessageManagerImpl.getMessages(docid, num, 20); + // 遍历文档消息列表 + for (Map map : result.getResultList()) { + // 如果消息中包含图片 ID + if (map.get("IMGID") != null) { + // 获取图片的访问 URL,并放入消息的 map 中 + map.put("IMGURL", farmFileManagerImpl.getFileURL(map.get("IMGID").toString())); + } + // 获取该消息的回复列表,并放入消息的 map 中 + map.put("replys", farmDocmessageManagerImpl.getReplys(docid, map.get("ID").toString())); + } + // 将消息的时间格式化为 "yyyy-MM-dd HH:mm:ss" + result.runformatTime("CTIME", "yyyy-MM-dd HH:mm:ss"); + // 将文档信息、热门文档信息、文档消息列表放入视图模型,并返回文档消息页面的视图模型 + return ViewMode.getInstance().putAttr("doc", doc).putAttr("hotdocs", hotdocs).putAttr("result", result) + .returnModelAndView(ThemesUtil.getThemePath() + "/know/docMessage"); + } catch (Exception e) { + // 如果捕获到异常,设置错误信息为异常信息,并返回错误页面的视图模型 + return ViewMode.getInstance().setError(e.getMessage()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + } + + /** + * 添加文档消息 + * + * @param docid 文档 ID + * @param content 消息内容 + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return ModelAndView 对象,重定向到文档消息页面 + */ + // 映射 /addmsg 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/addmsg") + public ModelAndView addMessage(String docid, String content, HttpSession session, HttpServletRequest request) { + try { + // 调用服务方法发送文档消息,消息类型为 "知识评论",操作类型为 "评论" + farmDocmessageManagerImpl.sendAnswering(content, "知识评论", "评论", docid, getCurrentUser(session)); + // 将文档 ID 放入视图模型,并重定向到文档消息页面 + return ViewMode.getInstance().putAttr("docid", docid).returnRedirectUrl("/webdocmessage/Pubmsg.do"); + } catch (Exception e) { + // 如果捕获到异常,设置错误信息为异常信息,并返回错误页面的视图模型 + return ViewMode.getInstance().setError(e.getMessage()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + } + + /** + * 赞同文档消息 + * + * @param id 文档消息 ID + * @param session HTTP 会话对象 + * @return 包含操作结果的 Map 对象 + */ + // 映射 /approveOf 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/approveOf") + // 将方法返回值直接作为响应体返回 + @ResponseBody + public Map approveOf(String id, HttpSession session) { + try { + // 调用服务方法对指定文档消息表示赞同 + FarmDocmessage farmDocmessage = farmDocmessageManagerImpl.approveOf(id, getCurrentUser(session)); + // 将处理后的文档消息放入视图模型,并返回操作结果 + return ViewMode.getInstance().putAttr("farmDocmessage", farmDocmessage).returnObjMode(); + } catch (Exception e) { + // 如果捕获到异常,返回操作结果(不包含错误信息) + return ViewMode.getInstance().returnObjMode(); + } + } + + /** + * 反对文档消息 + * + * @param id 文档消息 ID + * @param session HTTP 会话对象 + * @return 包含操作结果的 Map 对象 + */ + // 映射 /oppose 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/oppose") + // 将方法返回值直接作为响应体返回 + @ResponseBody + public Map oppose(String id, HttpSession session) { + try { + // 调用服务方法对指定文档消息表示反对 + FarmDocmessage farmDocmessage = farmDocmessageManagerImpl.oppose(id, getCurrentUser(session)); + // 将处理后的文档消息放入视图模型,并返回操作结果 + return ViewMode.getInstance().putAttr("farmDocmessage", farmDocmessage).returnObjMode(); + } catch (Exception e) { + // 如果捕获到异常,返回操作结果(不包含错误信息) + return ViewMode.getInstance().returnObjMode(); + } + } + + /** + * 回复文档消息 + * + * @param farmDocmessage 文档消息实体 + * @param session HTTP 会话对象 + * @return ModelAndView 对象,重定向到文档消息页面 + */ + // 映射 /reply 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/reply") + public ModelAndView reply(FarmDocmessage farmDocmessage, HttpSession session) { + try { + // 调用服务方法回复指定的文档消息 + farmDocmessageManagerImpl.reply(farmDocmessage.getContent(), farmDocmessage.getAppid(), + farmDocmessage.getId(), getCurrentUser(session)); + // 将文档 ID(消息所属的文档 ID)放入视图模型,并重定向到文档消息页面 + return ViewMode.getInstance().putAttr("docid", farmDocmessage.getAppid()) + .returnRedirectUrl("/webdocmessage/Pubmsg.do"); + } catch (Exception e) { + // 如果捕获到异常,设置错误信息为异常信息,并返回错误页面的视图模型 + return ViewMode.getInstance().setError(e.getMessage()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/wcp/controller/DocStatController.java b/src/wcp-web/src/main/java/com/farm/wcp/controller/DocStatController.java new file mode 100644 index 0000000..f14dc87 --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/controller/DocStatController.java @@ -0,0 +1,172 @@ +// 声明包路径 +package com.farm.wcp.controller; + +// 导入 Map 接口,用于处理键值对集合 +import java.util.Map; + +// 导入 Resource 注解,用于依赖注入 +import javax.annotation.Resource; +// 导入 HttpServletRequest 类,用于处理 HTTP 请求 +import javax.servlet.http.HttpServletRequest; +// 导入 HttpSession 类,用于管理用户会话 +import javax.servlet.http.HttpSession; + +// 导入 Controller 注解,将该类标记为 Spring MVC 的控制器 +import org.springframework.stereotype.Controller; +// 导入 RequestMapping 注解,用于映射请求路径 +import org.springframework.web.bind.annotation.RequestMapping; +// 导入 RequestMethod 枚举,用于指定请求方法 +import org.springframework.web.bind.annotation.RequestMethod; +// 导入 ResponseBody 注解,用于将方法的返回值直接写入响应体 +import org.springframework.web.bind.annotation.ResponseBody; +// 导入 ModelAndView 类,用于封装模型数据和视图信息 +import org.springframework.web.servlet.ModelAndView; + +// 导入 User 类,用于表示用户实体 +import com.farm.authority.domain.User; +// 导入 UserServiceInter 接口,用于用户服务相关操作 +import com.farm.authority.service.UserServiceInter; +// 导入 LoginUser 类,用于表示登录用户 +import com.farm.core.auth.domain.LoginUser; +// 导入 ViewMode 类,用于处理视图模式相关操作 +import com.farm.core.page.ViewMode; +// 导入 DataResult 类,用于处理数据库查询结果 +import com.farm.core.sql.result.DataResult; +// 导入 FarmDocManagerInter 接口,用于文档管理相关操作 +import com.farm.doc.server.FarmDocManagerInter; +// 导入 FarmDocOperateRightInter 接口,用于文档操作权限相关操作 +import com.farm.doc.server.FarmDocOperateRightInter; +// 导入 FarmDocRunInfoInter 接口,用于文档运行信息相关操作 +import com.farm.doc.server.FarmDocRunInfoInter; +// 导入 FarmDocgroupManagerInter 接口,用于文档小组管理相关操作 +import com.farm.doc.server.FarmDocgroupManagerInter; +// 导入 FarmDocmessageManagerInter 接口,用于文档消息管理相关操作 +import com.farm.doc.server.FarmDocmessageManagerInter; +// 导入 FarmFileManagerInter 接口,用于文件管理相关操作 +import com.farm.doc.server.FarmFileManagerInter; +// 导入 KnowServiceInter 接口,用于知识服务相关操作 +import com.farm.wcp.know.service.KnowServiceInter; +// 导入 ThemesUtil 类,用于处理主题相关的工具方法 +import com.farm.wcp.util.ThemesUtil; +// 导入 WebUtils 类,可能包含一些通用的 Web 工具方法 +import com.farm.web.WebUtils; + +/** + * 工作小组统计控制器类 + * + * @author wangdong + */ +// 定义请求映射的根路径为 /webstat +@RequestMapping("/webstat") +// 将该类标记为 Spring MVC 的控制器 +@Controller +// 继承 WebUtils 类,以获取其中的工具方法 +public class DocStatController extends WebUtils { + // 注入 FarmDocgroupManagerInter 接口的实现类实例,用于文档小组管理 + @Resource + private FarmDocgroupManagerInter farmDocgroupManagerImpl; + // 注入 FarmFileManagerInter 接口的实现类实例,用于文件管理 + @Resource + private FarmFileManagerInter farmFileManagerImpl; + // 注入 FarmDocManagerInter 接口的实现类实例,用于文档管理 + @Resource + private FarmDocManagerInter farmDocManagerImpl; + // 注入 FarmDocRunInfoInter 接口的实现类实例,用于文档运行信息管理 + @Resource + private FarmDocRunInfoInter farmDocRunInfoImpl; + // 注入 KnowServiceInter 接口的实现类实例,用于知识服务 + @Resource + private KnowServiceInter KnowServiceImpl; + // 注入 FarmDocmessageManagerInter 接口的实现类实例,用于文档消息管理 + @Resource + private FarmDocmessageManagerInter farmDocmessageManagerImpl; + // 注入 FarmDocOperateRightInter 接口的实现类实例,用于文档操作权限管理 + @Resource + private FarmDocOperateRightInter farmDocOperateRightImpl; + // 注入 UserServiceInter 接口的实现类实例,用于用户服务 + @Resource + private UserServiceInter userServiceImpl; + + /** + * 统计首页 + * + * @param pagenum 页码 + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return ModelAndView 对象,包含视图信息 + * @throws Exception 可能抛出的异常 + */ + // 映射 /PubHome 请求路径,处理 GET 请求 + @RequestMapping(value = "/PubHome", method = RequestMethod.GET) + public ModelAndView show(Integer pagenum, HttpSession session, HttpServletRequest request) throws Exception { + // 获取视图模式实例 + ViewMode mode = ViewMode.getInstance(); + // 获取好评用户排名数据,数量为 10 条 + DataResult goodUsers = farmDocRunInfoImpl.getStatGoodUsers(10); + // 将好评用户排名数据放入视图模型的属性中 + mode.putAttr("goodUsers", goodUsers); + // 获取好评小组排名数据,数量为 10 条 + DataResult goodGroups = farmDocRunInfoImpl.getStatGoodGroups(10); + // 将好评小组排名数据放入视图模型的属性中 + mode.putAttr("goodGroups", goodGroups); + // 获取用户发布排名数据,数量为 10 条 + DataResult manyUsers = farmDocRunInfoImpl.getStatMostUsers(10); + // 将用户发布排名数据放入视图模型的属性中 + mode.putAttr("manyUsers", manyUsers); + // 获取好评文章排名数据,数量为 10 条 + DataResult goodDocs = farmDocRunInfoImpl.getStatGoodDocs(10); + // 将好评文章排名数据放入视图模型的属性中 + mode.putAttr("goodDocs", goodDocs); + // 获取待完善文章排名数据,数量为 10 条 + DataResult badDocs = farmDocRunInfoImpl.getStatBadDocs(10); + // 将待完善文章排名数据放入视图模型的属性中 + mode.putAttr("badDocs", badDocs); + // 获取当前登录用户信息 + LoginUser luser = getCurrentUser(session); + // 如果当前用户存在 + if (luser != null) { + // 根据用户 ID 获取用户实体信息 + User user = userServiceImpl.getUserEntity(luser.getId()); + // 获取用户使用量分析数据 + DataResult users = farmDocRunInfoImpl.getStatUser(user); + // 如果用户的头像 ID 不为空且长度大于 0 + if (user.getImgid() != null && user.getImgid().trim().length() > 0) { + // 获取用户头像的文件 URL,并放入视图模型的属性中 + mode.putAttr("photourl", farmFileManagerImpl.getFileURL(user.getImgid())); + } + // 将用户使用量分析数据放入视图模型的属性中 + mode.putAttr("users", users); + } + // 返回统计首页的视图模型,视图路径通过 ThemesUtil 获取主题路径后拼接 + return mode.returnModelAndView(ThemesUtil.getThemePath() + "/statis/heros"); + } + + /** + * 获取所有统计数据(用于 wcp 使用量分析) + * + * @param session HTTP 会话对象 + * @param id 未使用的参数(可能预留) + * @return 包含统计数据的 Map 对象 + */ + // 映射 /PubStatAll 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/PubStatAll") + // 将方法返回值直接作为响应体返回 + @ResponseBody + public Map statAll(HttpSession session, String id) { + // 获取视图模式实例 + ViewMode mode = ViewMode.getInstance(); + // 用于存储 wcp 使用量分析的统计数据 + Map nums; + try { + // 获取按天统计的使用量数据 + nums = farmDocRunInfoImpl.getStatNumForDay(); + // 将统计数据放入视图模型的属性中 + mode.putAttr("nums", nums); + } catch (Exception e) { + // 如果捕获到异常,返回操作结果(不包含错误信息) + return mode.returnObjMode(); + } + // 返回操作结果 + return mode.returnObjMode(); + } +} diff --git a/src/wcp-web/src/main/java/com/farm/wcp/controller/DocTypeController.java b/src/wcp-web/src/main/java/com/farm/wcp/controller/DocTypeController.java new file mode 100644 index 0000000..e8350ed --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/controller/DocTypeController.java @@ -0,0 +1,133 @@ +// 声明包路径 +package com.farm.wcp.controller; + +// 导入 List 接口,用于处理有序集合 +import java.util.List; + +// 导入 Resource 注解,用于依赖注入 +import javax.annotation.Resource; +// 导入 HttpServletRequest 类,用于处理 HTTP 请求 +import javax.servlet.http.HttpServletRequest; +// 导入 HttpSession 类,用于管理用户会话 +import javax.servlet.http.HttpSession; + +// 导入 Controller 注解,将该类标记为 Spring MVC 的控制器 +import org.springframework.stereotype.Controller; +// 导入 PathVariable 注解,用于从 URL 路径中提取参数 +import org.springframework.web.bind.annotation.PathVariable; +// 导入 RequestMapping 注解,用于映射请求路径 +import org.springframework.web.bind.annotation.RequestMapping; +// 导入 RequestMethod 枚举,用于指定请求方法 +import org.springframework.web.bind.annotation.RequestMethod; +// 导入 ModelAndView 类,用于封装模型数据和视图信息 +import org.springframework.web.servlet.ModelAndView; + +// 导入 LoginUser 类,用于表示登录用户 +import com.farm.core.auth.domain.LoginUser; +// 导入 ViewMode 类,用于处理视图模式相关操作 +import com.farm.core.page.ViewMode; +// 导入 DataResult 类,用于处理数据库查询结果 +import com.farm.core.sql.result.DataResult; +// 导入 FarmDoctype 类,用于表示文档类型实体 +import com.farm.doc.domain.FarmDoctype; +// 导入 TypeBrief 类,用于表示类型的简要信息 +import com.farm.doc.domain.ex.TypeBrief; +// 导入 FarmDocManagerInter 接口,用于文档管理相关操作 +import com.farm.doc.server.FarmDocManagerInter; +// 导入 FarmDocOperateRightInter 接口,用于文档操作权限相关操作 +import com.farm.doc.server.FarmDocOperateRightInter; +// 导入 FarmDocRunInfoInter 接口,用于文档运行信息相关操作 +import com.farm.doc.server.FarmDocRunInfoInter; +// 导入 FarmDocTypeInter 接口,用于文档类型相关操作 +import com.farm.doc.server.FarmDocTypeInter; +// 导入 FarmDocgroupManagerInter 接口,用于文档小组管理相关操作 +import com.farm.doc.server.FarmDocgroupManagerInter; +// 导入 FarmDocmessageManagerInter 接口,用于文档消息管理相关操作 +import com.farm.doc.server.FarmDocmessageManagerInter; +// 导入 FarmFileManagerInter 接口,用于文件管理相关操作 +import com.farm.doc.server.FarmFileManagerInter; +// 导入 KnowServiceInter 接口,用于知识服务相关操作 +import com.farm.wcp.know.service.KnowServiceInter; +// 导入 ThemesUtil 类,用于处理主题相关的工具方法 +import com.farm.wcp.util.ThemesUtil; +// 导入 WebUtils 类,可能包含一些通用的 Web 工具方法 +import com.farm.web.WebUtils; + +// 定义请求映射的根路径为 /webtype +@RequestMapping("/webtype") +// 将该类标记为 Spring MVC 的控制器 +@Controller +// 继承 WebUtils 类,以获取其中的工具方法 +public class DocTypeController extends WebUtils { + // 注入 FarmDocgroupManagerInter 接口的实现类实例,用于文档小组管理 + @Resource + private FarmDocgroupManagerInter farmDocgroupManagerImpl; + // 注入 FarmFileManagerInter 接口的实现类实例,用于文件管理 + @Resource + private FarmFileManagerInter farmFileManagerImpl; + // 注入 FarmDocManagerInter 接口的实现类实例,用于文档管理 + @Resource + private FarmDocManagerInter farmDocManagerImpl; + // 注入 FarmDocRunInfoInter 接口的实现类实例,用于文档运行信息管理 + @Resource + private FarmDocRunInfoInter farmDocRunInfoImpl; + // 注入 KnowServiceInter 接口的实现类实例,用于知识服务 + @Resource + private KnowServiceInter KnowServiceImpl; + // 注入 FarmDocmessageManagerInter 接口的实现类实例,用于文档消息管理 + @Resource + private FarmDocmessageManagerInter farmDocmessageManagerImpl; + // 注入 FarmDocOperateRightInter 接口的实现类实例,用于文档操作权限管理 + @Resource + private FarmDocOperateRightInter farmDocOperateRightImpl; + // 注入 FarmDocTypeInter 接口的实现类实例,用于文档类型管理 + @Resource + private FarmDocTypeInter farmDocTypeManagerImpl; + + /** + * 分类首页 + * + * @param typeid 类型 ID,从 URL 路径中获取 + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @param pagenum 页码,从 URL 路径中获取 + * @return ModelAndView 对象,包含视图信息 + * @throws Exception 可能抛出的异常 + */ + // 映射 /view{typeid}/Pub{pagesize} 请求路径,处理 GET 请求,其中 {typeid} 和 {pagesize} 是路径参数 + @RequestMapping(value = "/view{typeid}/Pub{pagesize}", method = RequestMethod.GET) + public ModelAndView types(@PathVariable("typeid") String typeid, HttpSession session, HttpServletRequest request, + @PathVariable("pagesize") Integer pagenum) throws Exception { + // 获取视图模式实例 + ViewMode mode = ViewMode.getInstance(); + // 如果类型 ID 为空或空字符串 + if (typeid == null || typeid.isEmpty()) { + // 设置类型 ID 为 "NONE" + typeid = "NONE"; + } else { + // 根据类型 ID 获取类型信息,并放入视图模型的属性中 + mode.putAttr("type", farmDocTypeManagerImpl.getType(typeid)); + } + // 如果页码为 null + if (pagenum == null) { + // 设置页码为 1 + pagenum = 1; + } + // 获取当前登录用户信息 + LoginUser user = getCurrentUser(session); + // 将类型 ID 放入视图模型的属性中 + mode.putAttr("typeid", typeid); + // 获取指定类型的所有父类型路径 + List typepath = farmDocTypeManagerImpl.getTypeAllParent(typeid); + // 获取用户可见的流行文档类型简要信息 + List types = farmDocTypeManagerImpl.getPopTypesForReadDoc(getCurrentUser(session)); + // 获取用户可见的指定类型的子类型简要信息 + List typesons = farmDocTypeManagerImpl.getTypeInfos(getCurrentUser(session), typeid); + // 根据用户、类型 ID、数量(10)和页码获取该类型下的文档列表 + DataResult docs = farmDocTypeManagerImpl.getTypeDocs(user, typeid, 10, pagenum); + // 将流行文档类型信息、子类型信息、类型路径信息、文档列表信息放入视图模型,并返回分类首页的视图模型 + return mode.putAttr("types", types).putAttr("typesons", typesons).putAttr("typepath", typepath).putAttr("docs", docs) + .returnModelAndView(ThemesUtil.getThemePath() + "/type/type"); + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/wcp/controller/FrameController.java b/src/wcp-web/src/main/java/com/farm/wcp/controller/FrameController.java new file mode 100644 index 0000000..1cbea4d --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/controller/FrameController.java @@ -0,0 +1,184 @@ +// 声明包路径 +package com.farm.wcp.controller; + +// 导入 Method 类,用于反射获取方法信息 +import java.lang.reflect.Method; +// 导入 ArrayList 类,用于创建可变大小的列表 +import java.util.ArrayList; +// 导入 HashMap 类,用于创建键值对集合 +import java.util.HashMap; +// 导入 List 接口,用于处理有序集合 +import java.util.List; +// 导入 Map 接口,用于处理键值对集合 +import java.util.Map; + +// 导入 Resource 注解,用于依赖注入 +import javax.annotation.Resource; +// 导入 ServletContext 接口,用于获取 Servlet 上下文信息 +import javax.servlet.ServletContext; +// 导入 HttpServletRequest 类,用于处理 HTTP 请求 +import javax.servlet.http.HttpServletRequest; +// 导入 HttpSession 类,用于管理用户会话 +import javax.servlet.http.HttpSession; + +// 导入 Logger 类,用于日志记录 +import org.apache.log4j.Logger; +// 导入 Controller 注解,将该类标记为 Spring MVC 的控制器 +import org.springframework.stereotype.Controller; +// 导入 RequestMapping 注解,用于映射请求路径 +import org.springframework.web.bind.annotation.RequestMapping; +// 导入 ResponseBody 注解,用于将方法的返回值直接写入响应体 +import org.springframework.web.bind.annotation.ResponseBody; +// 导入 WebApplicationContext 接口,用于访问 Spring 应用上下文 +import org.springframework.web.context.WebApplicationContext; +// 导入 WebApplicationContextUtils 类,用于获取 Web 应用上下文 +import org.springframework.web.context.support.WebApplicationContextUtils; +// 导入 ModelAndView 类,用于封装模型数据和视图信息 +import org.springframework.web.servlet.ModelAndView; + +// 导入 ViewMode 类,用于处理视图模式相关操作 +import com.farm.core.page.ViewMode; +// 导入 DataQuery 类,可能用于数据库查询操作 +import com.farm.core.sql.query.DataQuery; +// 导入 FarmDocRunInfoInter 接口,用于文档运行信息相关操作 +import com.farm.doc.server.FarmDocRunInfoInter; +// 导入 WebUtils 类,可能包含一些通用的 Web 工具方法 +import com.farm.web.WebUtils; +// 导入 EasyUiTreeNode 类,用于表示 EasyUI 树节点 +import com.farm.web.easyui.EasyUiTreeNode; +// 导入 EasyUiUtils 类,用于处理 EasyUI 相关的工具方法 +import com.farm.web.easyui.EasyUiUtils; + +// 定义请求映射的根路径为 /frame +@RequestMapping("/frame") +// 将该类标记为 Spring MVC 的控制器 +@Controller +// 继承 WebUtils 类,以获取其中的工具方法 +public class FrameController extends WebUtils { + // 创建 Logger 实例,用于记录该类的日志信息 + private final static Logger log = Logger.getLogger(FrameController.class); + // 注入 FarmDocRunInfoInter 接口的实现类实例,用于文档运行信息管理 + @Resource + private FarmDocRunInfoInter farmDocRunInfoImpl; + + /** + * 访问系统框架首页 + * + * @param request HTTP 请求对象 + * @param session HTTP 会话对象 + * @return ModelAndView 对象,包含用户菜单信息和视图信息 + */ + // 映射 /index 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/index") + public ModelAndView index(HttpServletRequest request, HttpSession session) { + // 获取当前用户的菜单信息,并将其放入视图模型的属性中,然后返回框架首页的视图模型 + return ViewMode.getInstance().putAttr("menus", getCurrentUserMenus(session)).returnModelAndView("frame/frame"); + } + + /** + * 访问系统主页 + * + * @param request HTTP 请求对象 + * @param session HTTP 会话对象 + * @return ModelAndView 对象,包含统计信息和视图信息 + */ + // 映射 /home 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/home") + public ModelAndView home(HttpServletRequest request, HttpSession session) { + // 用于存储统计信息的 Map 对象 + Map map = null; + try { + // 获取统计信息 + map = farmDocRunInfoImpl.getStatNum(); + } catch (Exception e) { + // 如果获取统计信息失败,创建一个空的 HashMap + map = new HashMap<>(); + // 记录错误信息到日志中 + log.error(e.getMessage()); + } + // 将统计信息放入视图模型的属性中,并返回系统主页的视图模型 + return ViewMode.getInstance().putAttr("STAT", map).returnModelAndView("frame/home"); + } + + /** + * 获取所有的请求映射 URL 并以 EasyUI 树节点的形式返回 + * + * @param query 数据库查询对象(未使用) + * @param request HTTP 请求对象 + * @return 包含所有请求映射 URL 的 EasyUI 树节点列表 + */ + // 映射 /service 请求路径,处理默认请求(未指定方法,通常为 GET),并将返回值作为响应体返回 + @SuppressWarnings("unchecked") + @RequestMapping("/service") + @ResponseBody + public List allUrl(DataQuery query, HttpServletRequest request) { + // 记录日志,提示正式系统请关闭该服务 + log.info("正式系统请 关闭该服务"); + // 获取 Servlet 上下文对象 + ServletContext servletContext = request.getSession().getServletContext(); + // 获取 Web 应用上下文对象 + WebApplicationContext appContext = WebApplicationContextUtils.getWebApplicationContext(servletContext); + // 创建一个用于存储节点信息的列表 + List> list = new ArrayList>(); + // 获取所有被 @Controller 注解标记的 Bean 及其对应的映射信息 + Map allRequestMappings = appContext.getBeansWithAnnotation(Controller.class); + // 节点序号,初始值为 1 + int n = 1; + // 遍历所有被 @Controller 注解标记的 Bean + for (Object obj : allRequestMappings.values()) { + // 获取类上的 @RequestMapping 注解 + RequestMapping classRequest = obj.getClass().getAnnotation(RequestMapping.class); + // 如果类上存在 @RequestMapping 注解 + if (classRequest != null) { + // 封装父节点信息 + Map superNode = new HashMap(); + // 节点序号自增 + int m = ++n; + // 设置父节点的唯一标识 + superNode.put("SID", n); + // 设置父节点的父节点标识为 "none" + superNode.put("PID", "none"); + // 如果类的 @RequestMapping 注解的 value 为空或长度为 0 + if (classRequest.value() == null || classRequest.value().length == 0) { + // 设置父节点名称为 "NoGroup" + superNode.put("NA", "NoGroup"); + } else { + // 设置父节点名称为类的 @RequestMapping 注解的 value 值 + superNode.put("NA", classRequest.value()[0]); + } + // 将父节点信息添加到列表中 + list.add(superNode); + // 遍历类中的所有方法 + for (Method method : obj.getClass().getMethods()) { + // 获取方法上的 @RequestMapping 注解 + RequestMapping methodRequest = method.getAnnotation(RequestMapping.class); + // 如果方法上存在 @RequestMapping 注解 + if (methodRequest != null) { + // 封装子节点信息 + Map childeNode = new HashMap(); + // 节点序号自增 + n++; + // 设置子节点的唯一标识 + childeNode.put("SID", n); + // 设置子节点的父节点标识为父节点的序号 + childeNode.put("PID", m); + // 如果类的 @RequestMapping 注解的 value 为空或长度为 0 + if (classRequest.value() == null || classRequest.value().length == 0) { + // 设置子节点名称为方法的 @RequestMapping 注解的 value 值去掉 "/" 后的内容 + childeNode.put("NA", methodRequest.value()[0].replaceAll("/", "")); + } else { + // 设置子节点名称为类的 @RequestMapping 注解的 value 值去掉 "/" 后与方法的 @RequestMapping 注解的 value 值拼接的结果 + childeNode.put("NA", + classRequest.value()[0].replaceAll("/", "") + methodRequest.value()[0]); + } + // 将子节点信息添加到列表中 + list.add(childeNode); + } + } + } + } + // 将列表中的节点信息格式化为 EasyUI 树节点列表,并返回 + return (List) ViewMode.returnListObjMode(EasyUiUtils.formatAjaxTree(list, "PID", "SID", "NA")); + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/wcp/controller/IndexController.java b/src/wcp-web/src/main/java/com/farm/wcp/controller/IndexController.java new file mode 100644 index 0000000..6732e5e --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/controller/IndexController.java @@ -0,0 +1,289 @@ +// 声明包路径 +package com.farm.wcp.controller; + +// 导入 IOException 类,用于处理输入输出异常 +import java.io.IOException; +// 导入 OutputStream 类,用于输出流操作 +import java.io.OutputStream; +// 导入 URLEncoder 类,用于 URL 编码 +import java.net.URLEncoder; +// 导入 ArrayList 类,用于创建可变大小的列表 +import java.util.ArrayList; +// 导入 Hashtable 类,用于创建键值对集合 +import java.util.Hashtable; +// 导入 List 接口,用于处理有序集合 +import java.util.List; +// 导入 Map 接口,用于处理键值对集合 +import java.util.Map; + +// 导入 Resource 注解,用于依赖注入 +import javax.annotation.Resource; +// 导入 HttpServletRequest 类,用于处理 HTTP 请求 +import javax.servlet.http.HttpServletRequest; +// 导入 HttpServletResponse 类,用于处理 HTTP 响应 +import javax.servlet.http.HttpServletResponse; +// 导入 HttpSession 类,用于管理用户会话 +import javax.servlet.http.HttpSession; + +// 导入 Controller 注解,将该类标记为 Spring MVC 的控制器 +import org.springframework.stereotype.Controller; +// 导入 RequestMapping 注解,用于映射请求路径 +import org.springframework.web.bind.annotation.RequestMapping; +// 导入 ResponseBody 注解,用于将方法的返回值直接写入响应体 +import org.springframework.web.bind.annotation.ResponseBody; +// 导入 ModelAndView 类,用于封装模型数据和视图信息 +import org.springframework.web.servlet.ModelAndView; + +// 导入 ViewMode 类,用于处理视图模式相关操作 +import com.farm.core.page.ViewMode; +// 导入 DBRule 类,用于数据库查询规则 +import com.farm.core.sql.query.DBRule; +// 导入 DBSort 类,用于数据库查询排序 +import com.farm.core.sql.query.DBSort; +// 导入 DataQuery 类,用于数据库查询操作 +import com.farm.core.sql.query.DataQuery; +// 导入 DataResult 类,用于处理数据库查询结果 +import com.farm.core.sql.result.DataResult; +// 导入 DocBrief 类,用于表示文档的简要信息 +import com.farm.doc.domain.ex.DocBrief; +// 导入 GroupBrief 类,用于表示小组的简要信息 +import com.farm.doc.domain.ex.GroupBrief; +// 导入 TypeBrief 类,用于表示类型的简要信息 +import com.farm.doc.domain.ex.TypeBrief; +// 导入 FarmDocRunInfoInter 接口,用于文档运行信息相关操作 +import com.farm.doc.server.FarmDocRunInfoInter; +// 导入 FarmDocTypeInter 接口,用于文档类型相关操作 +import com.farm.doc.server.FarmDocTypeInter; +// 导入 FarmDocgroupManagerInter 接口,用于文档小组管理相关操作 +import com.farm.doc.server.FarmDocgroupManagerInter; +// 导入 FarmFileManagerInter 接口,用于文件管理相关操作 +import com.farm.doc.server.FarmFileManagerInter; +// 导入 FarmtopServiceInter 接口,功能不明确(从命名推测与顶部服务相关) +import com.farm.doc.server.FarmtopServiceInter; +// 导入 WeburlServiceInter 接口,功能不明确(从命名推测与 Web 网址服务相关) +import com.farm.doc.server.WeburlServiceInter; +// 导入 KnowServiceInter 接口,用于知识服务相关操作 +import com.farm.wcp.know.service.KnowServiceInter; +// 导入 ThemesUtil 类,用于处理主题相关的工具方法 +import com.farm.wcp.util.ThemesUtil; +// 导入 ZxingTowDCode 类,用于处理二维码相关操作(推测) +import com.farm.wcp.util.ZxingTowDCode; +// 导入 WebUtils 类,可能包含一些通用的 Web 工具方法 +import com.farm.web.WebUtils; +// 导入 BarcodeFormat 枚举,用于表示条形码格式 +import com.google.zxing.BarcodeFormat; +// 导入 EncodeHintType 枚举,用于设置编码提示类型 +import com.google.zxing.EncodeHintType; +// 导入 MultiFormatWriter 类,用于生成多种格式的条形码或二维码 +import com.google.zxing.MultiFormatWriter; +// 导入 BitMatrix 类,用于表示二维矩阵(用于存储生成的条形码或二维码的数据) +import com.google.zxing.common.BitMatrix; + +/** + * 文件相关的控制器类 + * + * @author autoCode + */ +// 定义请求映射的根路径为 /home +@RequestMapping("/home") +// 将该类标记为 Spring MVC 的控制器 +@Controller +// 继承 WebUtils 类,以获取其中的工具方法 +public class IndexController extends WebUtils { + // 注入 FarmDocRunInfoInter 接口的实现类实例,用于文档运行信息管理 + @Resource + private FarmDocRunInfoInter farmDocRunInfoImpl; + // 注入 KnowServiceInter 接口的实现类实例,用于知识服务 + @Resource + private KnowServiceInter KnowServiceImpl; + // 注入 FarmtopServiceInter 接口的实现类实例,用于相关顶部服务操作 + @Resource + private FarmtopServiceInter farmTopServiceImpl; + // 注入 FarmFileManagerInter 接口的实现类实例,用于文件管理 + @Resource + private FarmFileManagerInter farmFileManagerImpl; + // 注入 FarmDocTypeInter 接口的实现类实例,用于文档类型管理 + @Resource + private FarmDocTypeInter farmDocTypeManagerImpl; + // 注入 FarmDocgroupManagerInter 接口的实现类实例,用于文档小组管理 + @Resource + private FarmDocgroupManagerInter farmDocgroupManagerImpl; + // 注入 WeburlServiceInter 接口的实现类实例,用于相关 Web 网址服务操作 + @Resource + private WeburlServiceInter weburlServiceImpl; + + /** + * 访问系统首页 + * + * @param session HTTP 会话对象 + * @return ModelAndView 对象,包含各种数据信息和视图信息 + */ + // 映射 /Pubindex 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/Pubindex") + public ModelAndView index(HttpSession session) { + // 获取前五条置顶文档的简要信息 + List topdocs = farmDocRunInfoImpl.getPubTopDoc(5); + // 获取十条热门文档的简要信息 + List hotdocs = farmDocRunInfoImpl.getPubHotDoc(10); + // 获取六条最新知识文档的简要信息 + List newdocs = farmDocRunInfoImpl.getNewKnowList(6); + // 获取用户可见的流行文档类型简要信息 + List typesons = farmDocTypeManagerImpl.getPopTypesForReadDoc(getCurrentUser(session)); + // 获取十条最热小组的简要信息 + List groups = farmDocgroupManagerImpl.getHotDocGroups(10, getCurrentUser(session)); + // 将热门文档信息、最热小组信息、最新知识文档信息、文档类型信息、最新知识文档信息(重复添加,可能是笔误)、置顶文档信息放入视图模型,并返回首页的视图模型 + return ViewMode.getInstance().putAttr("hotdocs", hotdocs).putAttr("groups", groups).putAttr("docbriefs", newdocs) + .putAttr("typesons", typesons).putAttr("newdocs", newdocs).putAttr("topDocList", topdocs) + .returnModelAndView(ThemesUtil.getThemePath() + "/index"); + } + + /** + * 访问联系方式页面 + * + * @param session HTTP 会话对象 + * @return ModelAndView 对象,返回联系方式页面的视图模型 + */ + // 映射 /PubAbout 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/PubAbout") + public ModelAndView contact(HttpSession session) { + // 返回联系方式页面的视图模型 + return ViewMode.getInstance().returnModelAndView(ThemesUtil.getThemePath() + "/about"); + } + + /** + * 生成并展示二维码 + * + * @param request HTTP 请求对象 + * @param response HTTP 响应对象 + */ + // 映射 /PubQRCode 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/PubQRCode") + public void QRCode(HttpServletRequest request, HttpServletResponse response) { + // 输出流对象,用于输出二维码图片数据 + OutputStream outp = null; + try { + // 获取当前应用的上下文路径 + String path = request.getContextPath(); + // 构造要编码到二维码中的完整 URL + String text = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + path + + "/"; + // 二维码图片的宽度 + int width = 300; + // 二维码图片的高度 + int height = 300; + // 二维码的图片格式为 gif + String format = "gif"; + // 创建一个 Hashtable 用于设置编码提示信息 + Hashtable hints = new Hashtable(); + // 设置编码字符集为 utf-8 + hints.put(EncodeHintType.CHARACTER_SET, "utf-8"); + // 使用 MultiFormatWriter 生成二维码的 BitMatrix 对象 + BitMatrix bitMatrix = new MultiFormatWriter().encode(text, BarcodeFormat.QR_CODE, width, height, hints); + // 设置响应的内容类型为文件下载类型 + response.setContentType("application/x-download"); + // 给用户提供的下载文件名(这里只是一个占位符,未实际获取文件相关信息) + String filedisplay = "给用户提供的下载文件名"; + // 对文件名进行 URL 编码 + filedisplay = URLEncoder.encode(filedisplay, "UTF-8"); + // 添加响应头,设置文件下载的相关信息 + response.addHeader("Content-Disposition", "attachment;filename=" + filedisplay); + // 获取响应的输出流 + outp = response.getOutputStream(); + // 将生成的二维码数据写入输出流 + ZxingTowDCode.writeToStream(bitMatrix, format, outp); + + } catch (Exception e) { + // 打印异常堆栈信息 + e.printStackTrace(); + } finally { + // 如果输出流不为空,关闭输出流 + if (outp != null) { + try { + outp.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } + + /** + * 获取推荐服务列表 + * + * @return 包含推荐服务信息的 List 对象 + */ + // 映射 /PubrecommendServiceList 请求路径,处理默认请求(未指定方法,通常为 GET),并将返回值作为响应体返回 + @RequestMapping("/PubrecommendServiceList") + @ResponseBody + public List recommendServiceList() { + // 获取推荐服务的列表数据 + List> weburlList = weburlServiceImpl.getList(); + // 将列表数据格式化为特定的视图模式并返回 + return ViewMode.returnListObjMode(weburlList); + } + + /** + * 加载机构信息并返回相应视图 + * + * @param session HTTP 会话对象 + * @return ModelAndView 对象,包含机构信息和相应视图 + */ + // 映射 /PubFPloadOrgs 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/PubFPloadOrgs") + public ModelAndView userInfo(HttpSession session) { + try { + // 创建一个数据库查询对象,指定页码为 1,查询字段为 "ID,NAME,PARENTID",表名为 "alone_auth_organization" + DataQuery query = DataQuery.getInstance(1, "ID,NAME,PARENTID", "alone_auth_organization"); + // 添加查询规则,筛选出 STATE 字段值为 "1" 的记录 + query.addRule(new DBRule("STATE", "1", "=")); + // 添加排序规则,按 SORT 字段升序排序 + query.addSort(new DBSort("SORT", "ASC")); + // 执行查询并获取结果 + DataResult result = query.search(); + // 将查询结果放入视图模型,并返回相应的视图 + return ViewMode.getInstance().putAttr("result", result).returnModelAndView("web/user/commons/impl/pubOrg"); + } catch (Exception e) { + // 如果发生异常,设置错误信息并返回空视图 + return ViewMode.getInstance().setError(e.toString()).returnModelAndView(""); + } + } + + /** + * 按照名称查询知识信息 + * + * @param knowtitle 知识标题(查询条件) + * @return 包含查询结果信息的 Map 对象 + */ + // 映射 /FPsearchKnow 请求路径,处理默认请求(未指定方法,通常为 GET),并将返回值作为响应体返回 + @RequestMapping("/FPsearchKnow") + @ResponseBody + public Map FPsearchKnow(String knowtitle) { + try { + // 创建一个数据库查询对象,指定页码为 1,查询字段为 "TITLE,ID,DOMTYPE",表名为 "FARM_DOC" + DataQuery query = DataQuery.getInstance(1, "TITLE,ID,DOMTYPE", "FARM_DOC"); + // 如果知识标题不为空 + if (knowtitle != null) { + // 添加查询规则,按标题模糊匹配 + query.addRule(new DBRule("TITLE", knowtitle, "like")); + } else { + // 如果知识标题为空,按 ctime 字段降序排序 + query.addSort(new DBSort("ctime", "desc")); + } + // 添加查询规则,筛选出 STATE 字段值为 "1" 的记录 + query.addRule(new DBRule("STATE", "1", "=")); + // 添加额外的 SQL 查询条件 + query.addSqlRule(" and (READPOP='1' or READPOP='2')"); + // 执行查询并获取结果 + DataResult result = query.search(); + // 获取查询结果列表 + List> list = result.getResultList(); + // 将结果列表和列表大小放入视图模型,并返回相应的视图模式 + return ViewMode.getInstance().putAttr("list", list).putAttr("size", list.size()).returnObjMode(); + } catch (Exception e) { + // 如果发生异常,返回一个空的结果列表和大小为 0 的视图模式 + return ViewMode.getInstance().putAttr("list", new ArrayList>()).putAttr("size", 0) + .returnObjMode(); + } + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/wcp/controller/KnowController.java b/src/wcp-web/src/main/java/com/farm/wcp/controller/KnowController.java new file mode 100644 index 0000000..82021db --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/controller/KnowController.java @@ -0,0 +1,312 @@ +// 声明包路径 +package com.farm.wcp.controller; + +// 导入 List 接口,用于处理有序集合 +import java.util.List; + +// 导入 Resource 注解,用于依赖注入 +import javax.annotation.Resource; +// 导入 HttpServletRequest 类,用于处理 HTTP 请求 +import javax.servlet.http.HttpServletRequest; +// 导入 HttpSession 类,用于管理用户会话 +import javax.servlet.http.HttpSession; + +// 导入 Controller 注解,将该类标记为 Spring MVC 的控制器 +import org.springframework.stereotype.Controller; +// 导入 RequestMapping 注解,用于映射请求路径 +import org.springframework.web.bind.annotation.RequestMapping; +// 导入 ModelAndView 类,用于封装模型数据和视图信息 +import org.springframework.web.servlet.ModelAndView; + +// 导入 ViewMode 类,用于处理视图模式相关操作 +import com.farm.core.page.ViewMode; +// 导入 Doc 类,用于表示文档实体 +import com.farm.doc.domain.Doc; +// 导入 FarmDoctype 类,用于表示文档类型实体 +import com.farm.doc.domain.FarmDoctype; +// 导入 DocEntire 类,用于表示完整的文档信息 +import com.farm.doc.domain.ex.DocEntire; +// 导入 TypeBrief 类,用于表示类型的简要信息 +import com.farm.doc.domain.ex.TypeBrief; +// 导入 FarmDocManagerInter 接口,用于文档管理相关操作 +import com.farm.doc.server.FarmDocManagerInter; +// 导入 FarmDocOperateRightInter 接口,用于文档操作权限相关操作 +import com.farm.doc.server.FarmDocOperateRightInter; +// 导入 POP_TYPE 枚举,用于表示权限类型 +import com.farm.doc.server.FarmDocOperateRightInter.POP_TYPE; +// 导入 FarmDocRunInfoInter 接口,用于文档运行信息相关操作 +import com.farm.doc.server.FarmDocRunInfoInter; +// 导入 FarmDocTypeInter 接口,用于文档类型相关操作 +import com.farm.doc.server.FarmDocTypeInter; +// 导入 FarmDocgroupManagerInter 接口,用于文档小组管理相关操作 +import com.farm.doc.server.FarmDocgroupManagerInter; +// 导入 FarmDocmessageManagerInter 接口,用于文档消息管理相关操作 +import com.farm.doc.server.FarmDocmessageManagerInter; +// 导入 FarmFileManagerInter 接口,用于文件管理相关操作 +import com.farm.doc.server.FarmFileManagerInter; +// 导入 KnowServiceInter 接口,用于知识服务相关操作 +import com.farm.wcp.know.service.KnowServiceInter; +// 导入 ThemesUtil 类,用于处理主题相关的工具方法 +import com.farm.wcp.util.ThemesUtil; +// 导入 WebUtils 类,可能包含一些通用的 Web 工具方法 +import com.farm.web.WebUtils; + +/** + * 知识相关操作的控制器类 + * + * @author autoCode + */ +// 定义请求映射的根路径为 /know +@RequestMapping("/know") +// 将该类标记为 Spring MVC 的控制器 +@Controller +// 继承 WebUtils 类,以获取其中的工具方法 +public class KnowController extends WebUtils { + // 注入 FarmDocgroupManagerInter 接口的实现类实例,用于文档小组管理 + @Resource + private FarmDocgroupManagerInter farmDocgroupManagerImpl; + // 注入 FarmFileManagerInter 接口的实现类实例,用于文件管理 + @Resource + private FarmFileManagerInter farmFileManagerImpl; + // 注入 FarmDocManagerInter 接口的实现类实例,用于文档管理 + @Resource + private FarmDocManagerInter farmDocManagerImpl; + // 注入 FarmDocRunInfoInter 接口的实现类实例,用于文档运行信息管理 + @Resource + private FarmDocRunInfoInter farmDocRunInfoImpl; + // 注入 KnowServiceInter 接口的实现类实例,用于知识服务 + @Resource + private KnowServiceInter KnowServiceImpl; + // 注入 FarmDocmessageManagerInter 接口的实现类实例,用于文档消息管理 + @Resource + private FarmDocmessageManagerInter farmDocmessageManagerImpl; + // 注入 FarmDocOperateRightInter 接口的实现类实例,用于文档操作权限管理 + @Resource + private FarmDocOperateRightInter farmDocOperateRightImpl; + // 注入 FarmDocTypeInter 接口的实现类实例,用于文档类型管理 + @Resource + private FarmDocTypeInter farmDocTypeManagerImpl; + + /** + * 进入创建知识页面 + * + * @param typeid 文档类型 ID + * @param groupid 文档小组 ID + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return ModelAndView 对象,包含类型信息和文档信息以及视图信息 + */ + // 映射 /add 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/add") + public ModelAndView add(String typeid, String groupid, HttpSession session, HttpServletRequest request) { + // 获取当前用户可用于撰写文档的类型简要信息列表 + List types = farmDocTypeManagerImpl.getTypesForWriteDoc(getCurrentUser(session)); + + // 创建一个完整的文档信息对象 + DocEntire doce = new DocEntire(); + // 根据类型 ID 获取文档类型信息 + FarmDoctype doctype = farmDocTypeManagerImpl.getType(typeid); + // 将文档类型信息设置到完整文档信息对象中 + doce.setType(doctype); + + // 创建一个文档对象 + Doc doc = new Doc(); + // 设置文档所属小组 ID + doc.setDocgroupid(groupid); + // 将文档对象设置到完整文档信息对象中 + doce.setDoc(doc); + + // 将类型信息和完整文档信息放入视图模型,并返回创建知识页面的视图模型 + return ViewMode.getInstance().putAttr("types", types).putAttr("doce", doce) + .returnModelAndView(ThemesUtil.getThemePath() + "/know/creat"); + } + + /** + * 进入网页输入页面 + * + * @param typeid 文档类型 ID + * @param groupid 文档小组 ID + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return ModelAndView 对象,包含类型 ID 和小组 ID 以及视图信息 + */ + // 映射 /webdown 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/webdown") + public ModelAndView downWeb(String typeid, String groupid, HttpSession session, HttpServletRequest request) { + // 将类型 ID 和小组 ID 放入视图模型,并返回网页输入页面的视图模型 + return ViewMode.getInstance().putAttr("typeid", typeid).putAttr("groupid", groupid) + .returnModelAndView(ThemesUtil.getThemePath() + "/know/downWeb"); + } + + /** + * 提交并下载网页创建知识 + * + * @param url 网页 URL + * @param typeid 文档类型 ID + * @param groupid 文档小组 ID + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return ModelAndView 对象,包含文档信息和类型信息以及视图信息 + */ + // 映射 /webDLoadDo 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/webDLoadDo") + public ModelAndView downWebCommit(String url, String typeid, String groupid, HttpSession session, + HttpServletRequest request) { + try { + // 根据网页 URL 和当前用户信息获取完整的文档信息 + DocEntire doc = KnowServiceImpl.getDocByWeb(url, getCurrentUser(session)); + // 如果类型 ID 不为空且不等于 "NONE" + if (typeid != null && !typeid.toUpperCase().trim().equals("NONE") + && !typeid.toUpperCase().trim().equals("")) { + // 根据类型 ID 获取文档类型信息 + FarmDoctype doctype = farmDocTypeManagerImpl.getType(typeid); + // 将文档类型信息设置到完整文档信息对象中 + doc.setType(doctype); + } + // 如果小组 ID 不为空且不等于 "NONE" + if (groupid != null && !groupid.toUpperCase().trim().equals("NONE") + && !groupid.toUpperCase().trim().equals("")) { + // 设置文档所属小组 ID + doc.getDoc().setDocgroupid(groupid); + } + // 获取当前用户可用于撰写文档的类型简要信息列表 + List types = farmDocTypeManagerImpl.getTypesForWriteDoc(getCurrentUser(session)); + // 将文档信息和类型信息放入视图模型,并返回创建知识页面的视图模型 + return ViewMode.getInstance().putAttr("doce", doc).putAttr("types", types) + .returnModelAndView(ThemesUtil.getThemePath() + "/know/creat"); + } catch (Exception e) { + // 如果发生异常,设置错误信息并返回错误页面的视图模型 + return ViewMode.getInstance().setError(e.toString()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + } + + /** + * 进入修改知识页面 + * + * @param docid 文档 ID + * @param session HTTP 会话对象 + * @return ModelAndView 对象,包含文档信息和类型信息以及视图信息 + */ + // 映射 /edit 请求路径,处理默认请求(未指定方法,通常为 GET) + @SuppressWarnings("deprecation") + @RequestMapping("/edit") + public ModelAndView edit(String docid, HttpSession session) { + // 完整的文档信息对象 + DocEntire doce = null; + try { + // 根据文档 ID 获取完整的文档信息 + doce = farmDocManagerImpl.getDoc(docid); + // 解决 kindedit 中 HTML 脚本被转义的问题 + doce.getTexts() + .setText1(doce.getTexts().getText1().replaceAll(">", "&gt;").replaceAll("<", "&lt;")); + + } catch (Exception e) { + // 打印异常堆栈信息 + e.printStackTrace(); + } + // 获取当前用户可用于撰写文档的类型简要信息列表 + List types = farmDocTypeManagerImpl.getTypesForWriteDoc(getCurrentUser(session)); + // 将文档信息和类型信息放入视图模型,并返回修改知识页面的视图模型 + return ViewMode.getInstance().putAttr("doce", doce).putAttr("types", types) + .returnModelAndView(ThemesUtil.getThemePath() + "/know/edit"); + } + + /** + * 提交创建知识请求 + * + * @param docgroup 文档小组 ID + * @param knowtitle 知识标题 + * @param knowtype 知识类型 + * @param text 知识内容 + * @param knowtag 知识标签 + * @param writetype 写入权限类型 + * @param readtype 读取权限类型 + * @param session HTTP 会话对象 + * @return ModelAndView 对象,根据创建结果进行重定向 + */ + // 映射 /addsubmit 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/addsubmit") + public ModelAndView submitAdd(String docgroup, String knowtitle, String knowtype, String text, String knowtag, + String writetype, String readtype, HttpSession session) { + // 完整的文档信息对象 + DocEntire doc = null; + try { + // 如果文档小组 ID 为 "0",则将其设置为 null + if ("0".equals(docgroup)) { + docgroup = null; + } + // 调用服务层方法创建知识文档 + doc = KnowServiceImpl.creatKnow(knowtitle, knowtype, text, knowtag, POP_TYPE.getEnum(writetype), + POP_TYPE.getEnum(readtype), docgroup, getCurrentUser(session)); + } catch (Exception e) { + // 如果发生异常,设置错误信息并返回错误页面的视图模型 + return ViewMode.getInstance().setError(e.toString()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + // 如果文档有审核信息 + if (doc.getAudit() != null) { + // 重定向到审核页面 + return ViewMode.getInstance().returnRedirectUrl("/audit/tempdoc.do?auditid=" + doc.getAudit().getId()); + } + // 重定向到文档查看页面 + return ViewMode.getInstance().returnRedirectUrl("/webdoc/view/Pub" + doc.getDoc().getId() + ".html"); + } + + /** + * 提交修改知识请求 + * + * @param docid 文档 ID + * @param docgroup 文档小组 ID + * @param knowtitle 知识标题 + * @param knowtype 知识类型 + * @param text 知识内容 + * @param knowtag 知识标签 + * @param writetype 写入权限类型 + * @param readtype 读取权限类型 + * @param editNote 修改备注 + * @param session HTTP 会话对象 + * @return ModelAndView 对象,根据修改结果进行重定向 + */ + // 映射 /editsubmit 请求路径,处理默认请求(未指定方法,通常为 GET) + @SuppressWarnings("deprecation") + @RequestMapping("/editsubmit") + public ModelAndView editCommit(String docid, String docgroup, String knowtitle, String knowtype, String text, + String knowtag, String writetype, String readtype, String editNote, HttpSession session) { + // 完整的文档信息对象 + DocEntire doc = null; + try { + // 如果文档小组 ID 为 "0",则将其设置为 null + if ("0".equals(docgroup)) { + docgroup = null; + } + // 如果当前用户有删除该文档的权限(高级权限用户) + if (farmDocOperateRightImpl.isDel(getCurrentUser(session), farmDocManagerImpl.getDocOnlyBean(docid))) { + // 调用服务层方法进行高级权限的知识修改 + doc = KnowServiceImpl.editKnow(docid, knowtitle, knowtype, text, knowtag, POP_TYPE.getEnum(writetype), + POP_TYPE.getEnum(readtype), docgroup, getCurrentUser(session), editNote); + // 重定向到文档查看页面 + return ViewMode.getInstance().returnRedirectUrl("/webdoc/view/Pub" + docid + ".html"); + } + // 低级权限用户修改 + { + // 调用服务层方法进行低级权限的知识修改 + doc = KnowServiceImpl.editKnow(docid, text, knowtag, getCurrentUser(session), editNote); + } + } catch (Exception e) { + // 获取当前用户可用于撰写文档的类型简要信息列表 + List types = farmDocTypeManagerImpl.getTypesForWriteDoc(getCurrentUser(session)); + // 将文档信息和类型信息放入视图模型,并返回修改知识页面的视图模型 + return ViewMode.getInstance().putAttr("doce", farmDocManagerImpl.getDoc(doc.getDoc().getId())) + .putAttr("types", types).returnModelAndView(ThemesUtil.getThemePath() + "/know/edit"); + } + // 如果文档有审核信息 + if (doc.getAudit() != null) { + // 重定向到审核页面 + return ViewMode.getInstance().returnRedirectUrl("/audit/tempdoc.do?auditid=" + doc.getAudit().getId()); + } + // 重定向到文档查看页面 + return ViewMode.getInstance().returnRedirectUrl("/webdoc/view/Pub" + doc.getDoc().getId() + ".html"); + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/wcp/controller/LoginController.java b/src/wcp-web/src/main/java/com/farm/wcp/controller/LoginController.java new file mode 100644 index 0000000..cd89e6a --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/controller/LoginController.java @@ -0,0 +1,231 @@ +// 声明包路径 +package com.farm.wcp.controller; + +// 导入 HttpServletRequest 类,用于处理 HTTP 请求 +import javax.servlet.http.HttpServletRequest; +// 导入 HttpSession 类,用于管理用户会话 +import javax.servlet.http.HttpSession; + +// 导入 Logger 类,用于日志记录 +import org.apache.log4j.Logger; +// 导入 Controller 注解,将该类标记为 Spring MVC 的控制器 +import org.springframework.stereotype.Controller; +// 导入 RequestMapping 注解,用于映射请求路径 +import org.springframework.web.bind.annotation.RequestMapping; +// 导入 ModelAndView 类,用于封装模型数据和视图信息 +import org.springframework.web.servlet.ModelAndView; + +// 导入 FarmAuthorityService 类,用于权限相关操作 +import com.farm.authority.FarmAuthorityService; +// 导入 LoginUserNoExistException 异常类,用于处理用户不存在的情况 +import com.farm.core.auth.exception.LoginUserNoExistException; +// 导入 ViewMode 类,用于处理视图模式相关操作 +import com.farm.core.page.ViewMode; +// 导入 DefaultIndexPageTaget 类,用于获取默认首页相关信息 +import com.farm.doc.tag.DefaultIndexPageTaget; +// 导入 FarmParameterService 类,用于获取参数相关操作 +import com.farm.parameter.FarmParameterService; +// 导入 ThemesUtil 类,用于处理主题相关的工具方法 +import com.farm.wcp.util.ThemesUtil; +// 导入 WebUtils 类,可能包含一些通用的 Web 工具方法 +import com.farm.web.WebUtils; +// 导入 OnlineUserOpImpl 类,用于处理在线用户操作的实现类 +import com.farm.web.online.OnlineUserOpImpl; +// 导入 OnlineUserOpInter 接口,用于处理在线用户操作 +import com.farm.web.online.OnlineUserOpInter; + +// 定义请求映射的根路径为 /login +@RequestMapping("/login") +// 将该类标记为 Spring MVC 的控制器 +@Controller +// 继承 WebUtils 类,以获取其中的工具方法 +public class LoginController extends WebUtils { + // 创建 Logger 实例,用于记录该类的日志信息 + private final static Logger log = Logger.getLogger(LoginController.class); + + /** + * 处理登录提交请求 + * + * @param name 用户名 + * @param password 密码 + * @param request HTTP 请求对象 + * @param session HTTP 会话对象 + * @return ModelAndView 对象,根据登录结果进行重定向或返回登录页面 + */ + // 映射 /submit 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/submit") + public ModelAndView loginCommit(String name, String password, HttpServletRequest request, HttpSession session) { + try { + // 检查用户名和密码是否合法 + if (FarmAuthorityService.getInstance().isLegality(name, password)) { + // 登录成功 + // 将用户信息注册到 session 中 + loginIntoSession(session, getCurrentIp(request), name); + // 重定向到系统框架首页 + return ViewMode.getInstance().returnRedirectUrl("/frame/index.do"); + } else { + // 登录失败 + // 将错误信息放入视图模型,并返回登录页面 + return ViewMode.getInstance().putAttr("message", "用户密码错误").returnModelAndView("frame/login"); + } + } catch (LoginUserNoExistException e) { + // 记录当前用户不存在的日志信息 + log.info("当前用户不存在"); + // 将错误信息放入视图模型,并返回登录页面 + return ViewMode.getInstance().putAttr("message", "当前用户不存在").returnModelAndView("frame/login"); + } + } + + /** + * 进入登录页面 + * + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return ModelAndView 对象,返回登录页面的视图模型 + */ + // 映射 /webPage 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/webPage") + public ModelAndView login(HttpSession session, HttpServletRequest request) { + // 获取登录前的页面地址 + String url = request.getHeader("Referer"); + // 将登录前的页面地址存入 session 中 + session.setAttribute(FarmParameterService.getInstance().getParameter("farm.constant.session.key.from.url"), + url); + // 返回登录页面的视图模型 + return ViewMode.getInstance().returnModelAndView(ThemesUtil.getThemePath() + "/login"); + } + + /** + * 处理网页登录提交请求 + * + * @param name 用户名 + * @param password 密码 + * @param request HTTP 请求对象 + * @param session HTTP 会话对象 + * @return ModelAndView 对象,根据登录结果进行重定向或返回登录页面 + */ + // 映射 /websubmit 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/websubmit") + public ModelAndView webLoginCommit(String name, String password, HttpServletRequest request, HttpSession session) { + try { + // 检查用户名和密码是否合法 + if (FarmAuthorityService.getInstance().isLegality(name, password)) { + // 登录成功 + // 将用户信息注册到 session 中 + loginIntoSession(session, getCurrentIp(request), name); + // 要跳转的目标 URL + String goUrl = null; + if (goUrl == null) { + // 从 session 中获取要去的目标 URL + goUrl = (String) session.getAttribute( + FarmParameterService.getInstance().getParameter("farm.constant.session.key.go.url")); + // 从 session 中移除该目标 URL + session.removeAttribute( + FarmParameterService.getInstance().getParameter("farm.constant.session.key.go.url")); + } + if (goUrl == null) { + // 从 session 中获取登录前的页面地址 + goUrl = (String) session.getAttribute( + FarmParameterService.getInstance().getParameter("farm.constant.session.key.from.url")); + } + if (goUrl != null && goUrl.indexOf("login/webPage") > 0) { + // 如果返回的是登录页面,则设置目标 URL 为 null,即去默认首页 + goUrl = null; + } + if (goUrl == null) { + // 设置默认页面为目标 URL + goUrl = "/" + DefaultIndexPageTaget.getDefaultIndexPage(); + } + // 重定向到目标 URL + return ViewMode.getInstance().returnRedirectUrl(goUrl); + } else { + // 登录失败 + // 将用户名和错误信息放入视图模型,并返回登录页面 + return ViewMode.getInstance().putAttr("loginname", name).setError("用户密码错误") + .returnModelAndView(ThemesUtil.getThemePath() + "/login"); + } + } catch (LoginUserNoExistException e) { + // 记录当前用户不存在的日志信息 + log.info("当前用户不存在"); + // 将用户名和错误信息放入视图模型,并返回登录页面 + return ViewMode.getInstance().putAttr("loginname", name).setError("当前用户不存在") + .returnModelAndView(ThemesUtil.getThemePath() + "/login"); + } + } + + /** + * 处理用户登出请求(网页登出) + * + * @param name 用户名(未使用) + * @param session HTTP 会话对象 + * @return ModelAndView 对象,重定向到默认首页 + */ + // 映射 /webout 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/webout") + public ModelAndView weblogOut(String name, HttpSession session) { + // 清除当前用户的 session 信息 + clearCurrentUser(session); + // 重定向到默认首页 + return ViewMode.getInstance().returnRedirectUrl("/" + DefaultIndexPageTaget.getDefaultIndexPage()); + } + + /** + * 进入登录页面(重载方法,仅接收用户名参数) + * + * @param name 用户名(未使用) + * @return ModelAndView 对象,返回登录页面的视图模型 + */ + // 映射 /page 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/page") + public ModelAndView login(String name) { + // 返回登录页面的视图模型 + return ViewMode.getInstance().returnModelAndView("frame/login"); + } + + /** + * 处理用户登出请求 + * + * @param name 用户名(未使用) + * @param session HTTP 会话对象 + * @return ModelAndView 对象,重定向到登录页面 + */ + // 映射 /out 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/out") + public ModelAndView logOut(String name, HttpSession session) { + // 清除当前用户的 session 信息 + clearCurrentUser(session); + // 重定向到登录页面 + return ViewMode.getInstance().returnRedirectUrl("/login/page.do"); + } + + /** + * 将登录信息写入 session + * + * @param session HTTP 会话对象 + * @param ip 用户的 IP 地址 + * @param loginName 登录用户名 + */ + private void loginIntoSession(HttpSession session, String ip, String loginName) { + // 开始写入 session 用户信息 + // 根据登录用户名获取用户信息并设置到 session 中 + setCurrentUser(FarmAuthorityService.getInstance().getUserByLoginName(loginName), session); + // 设置用户登录时间到 session 中 + setLoginTime(session); + // 开始写入 session 用户权限 + // 根据当前用户 ID 获取用户权限键并设置到 session 中 + setCurrentUserAction(FarmAuthorityService.getInstance().getUserAuthKeys(getCurrentUser(session).getId()), + session); + // 开始写入 session 用户菜单 + // 根据当前用户 ID 获取用户菜单并设置到 session 中 + setCurrentUserMenu(FarmAuthorityService.getInstance().getUserMenu(getCurrentUser(session).getId()), session); + // 写入用户上线信息 + // 创建在线用户操作对象 + OnlineUserOpInter ouop = null; + ouop = OnlineUserOpImpl.getInstance(ip, loginName, session); + // 处理用户登录操作 + ouop.userLoginHandle(FarmAuthorityService.getInstance().getUserByLoginName(loginName)); + // 记录用户登录时间(调用权限服务的方法) + FarmAuthorityService.getInstance().loginHandle(getCurrentUser(session).getId()); + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/wcp/controller/UserController.java b/src/wcp-web/src/main/java/com/farm/wcp/controller/UserController.java new file mode 100644 index 0000000..41f2526 --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/controller/UserController.java @@ -0,0 +1,464 @@ +// 声明包路径 +package com.farm.wcp.controller; + +// 导入 SQLException 异常类,用于处理 SQL 相关异常 +import java.sql.SQLException; +// 导入 Map 接口,用于存储键值对 +import java.util.Map; + +// 导入 Resource 注解,用于依赖注入 +import javax.annotation.Resource; +// 导入 HttpSession 类,用于管理用户会话 +import javax.servlet.http.HttpSession; + +// 导入 Controller 注解,将该类标记为 Spring MVC 的控制器 +import org.springframework.stereotype.Controller; +// 导入 RequestMapping 注解,用于映射请求路径 +import org.springframework.web.bind.annotation.RequestMapping; +// 导入 ResponseBody 注解,用于将返回值直接作为响应体 +import org.springframework.web.bind.annotation.ResponseBody; +// 导入 ModelAndView 类,用于封装模型数据和视图信息 +import org.springframework.web.servlet.ModelAndView; + +// 导入 Organization 类,用于表示组织机构实体 +import com.farm.authority.domain.Organization; +// 导入 User 类,用于表示用户实体 +import com.farm.authority.domain.User; +// 导入 UserServiceInter 接口,用于用户服务相关操作 +import com.farm.authority.service.UserServiceInter; +// 导入 ViewMode 类,用于处理视图模式相关操作 +import com.farm.core.page.ViewMode; +// 导入 DataResult 类,用于封装数据查询结果 +import com.farm.core.sql.result.DataResult; +// 导入 FarmDocManagerInter 接口,用于文档管理相关操作 +import com.farm.doc.server.FarmDocManagerInter; +// 导入 FarmDocOperateRightInter 接口,用于文档操作权限相关操作 +import com.farm.doc.server.FarmDocOperateRightInter; +// 导入 FarmDocRunInfoInter 接口,用于文档运行信息相关操作 +import com.farm.doc.server.FarmDocRunInfoInter; +// 导入 FarmDocgroupManagerInter 接口,用于文档小组管理相关操作 +import com.farm.doc.server.FarmDocgroupManagerInter; +// 导入 FarmDocmessageManagerInter 接口,用于文档消息管理相关操作 +import com.farm.doc.server.FarmDocmessageManagerInter; +// 导入 FarmFileManagerInter 接口,用于文件管理相关操作 +import com.farm.doc.server.FarmFileManagerInter; +// 导入 UsermessageServiceInter 接口,用于用户消息服务相关操作 +import com.farm.doc.server.UsermessageServiceInter; +// 导入 DocumentConfig 类,用于文档配置相关操作 +import com.farm.doc.server.commons.DocumentConfig; +// 导入 FarmParameterService 类,用于获取参数相关操作 +import com.farm.parameter.FarmParameterService; +// 导入 KnowServiceInter 接口,用于知识服务相关操作 +import com.farm.wcp.know.service.KnowServiceInter; +// 导入 ThemesUtil 类,用于处理主题相关的工具方法 +import com.farm.wcp.util.ThemesUtil; +// 导入 WebUtils 类,可能包含一些通用的 Web 工具方法 +import com.farm.web.WebUtils; + +/** + * 用户相关操作的控制器类,处理用户信息修改、注册、首页展示等功能 + * + * @author autoCode + */ +// 定义请求映射的根路径为 /webuser +@RequestMapping("/webuser") +// 将该类标记为 Spring MVC 的控制器 +@Controller +// 继承 WebUtils 类,以获取其中的工具方法 +public class UserController extends WebUtils { + // 注入 FarmDocgroupManagerInter 接口的实现类实例,用于文档小组管理 + @Resource + private FarmDocgroupManagerInter farmDocgroupManagerImpl; + // 注入 FarmFileManagerInter 接口的实现类实例,用于文件管理 + @Resource + private FarmFileManagerInter farmFileManagerImpl; + // 注入 FarmDocManagerInter 接口的实现类实例,用于文档管理 + @Resource + private FarmDocManagerInter farmDocManagerImpl; + // 注入 FarmDocRunInfoInter 接口的实现类实例,用于文档运行信息管理 + @Resource + private FarmDocRunInfoInter farmDocRunInfoImpl; + // 注入 KnowServiceInter 接口的实现类实例,用于知识服务 + @Resource + private KnowServiceInter knowServiceImpl; + // 注入 FarmDocmessageManagerInter 接口的实现类实例,用于文档消息管理 + @Resource + private FarmDocmessageManagerInter farmDocmessageManagerImpl; + // 注入 FarmDocOperateRightInter 接口的实现类实例,用于文档操作权限管理 + @Resource + private FarmDocOperateRightInter farmDocOperateRightImpl; + // 注入 UserServiceInter 接口的实现类实例,用于用户服务 + @Resource + private UserServiceInter userServiceImpl; + // 注入 UsermessageServiceInter 接口的实现类实例,用于用户消息服务 + @Resource + private UsermessageServiceInter usermessageServiceImpl; + + // 原本的日志记录器定义被注释掉了 + // private final static Logger log = Logger.getLogger(UserController.class); + + /** + * 进入修改用户信息页面 + * + * @param session HTTP 会话对象 + * @return ModelAndView 对象,包含用户信息、头像信息、机构信息等,并返回修改用户信息页面的视图模型 + */ + // 映射 /edit 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/edit") + public ModelAndView editUser(HttpSession session) { + try { + // 根据当前用户的 ID 获取用户实体 + User user = userServiceImpl.getUserEntity(getCurrentUser(session).getId()); + // 获取用户名称 + String name = user.getName(); + // 获取用户头像 ID + String photoid = user.getImgid(); + // 初始化头像 URL 为 null + String photourl = null; + // 如果头像 ID 不为空 + if (photoid != null && photoid.trim().length() > 0) { + // 根据头像 ID 获取头像 URL + photourl = farmFileManagerImpl.getFileURL(photoid); + } + + // 根据用户 ID 获取用户所在的组织机构 + Organization org = userServiceImpl.getOrg(user.getId()); + // 从配置参数中获取是否显示机构的配置信息 + String showOrgStr = FarmParameterService.getInstance().getParameter("config.regist.showOrg"); + // 初始化是否显示机构的标志为 false + boolean showOrg = false; + // 如果配置信息为 "true" + if ("true".equals(showOrgStr)) { + // 设置显示机构的标志为 true + showOrg = true; + } + + // 将用户信息、名称、头像 ID、机构信息、是否显示机构标志、头像 URL 放入视图模型,并返回修改用户信息页面的视图模型 + return ViewMode.getInstance().putAttr("user", user).putAttr("name", name).putAttr("photoid", photoid) + .putAttr("org", org).putAttr("showOrg", showOrg).putAttr("photourl", photourl) + .returnModelAndView(ThemesUtil.getThemePath() + "/user/userInfoEdit"); + } catch (Exception e) { + // 如果发生异常,设置错误信息并返回修改用户信息页面的视图模型 + return ViewMode.getInstance().setError(e.toString()).returnModelAndView(ThemesUtil.getThemePath() + "/user/userInfoEdit"); + } + } + + /** + * 进入用户注册页面 + * + * @param session HTTP 会话对象 + * @return ModelAndView 对象,包含默认头像 URL 和是否显示机构标志,并返回用户注册页面的视图模型 + */ + // 映射 /PubRegist 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/PubRegist") + public ModelAndView regist(HttpSession session) { + // 从配置参数中获取是否显示机构的配置信息 + String showOrgStr = FarmParameterService.getInstance().getParameter("config.regist.showOrg"); + // 初始化是否显示机构的标志为 false + boolean showOrg = false; + // 如果配置信息为 "true" + if ("true".equals(showOrgStr)) { + // 设置显示机构的标志为 true + showOrg = true; + } + // 将默认头像 URL 和是否显示机构标志放入视图模型,并返回用户注册页面的视图模型 + return ViewMode.getInstance() + .putAttr("imgUrl", DocumentConfig.getString("config.doc.download.url") + "402888ac501d764801501d817b9e0011") + .putAttr("showOrg", showOrg).returnModelAndView(ThemesUtil.getThemePath() + "/user/regist"); + } + + /** + * 处理用户注册提交请求 + * + * @param photoid 用户头像 ID + * @param loginname 用户登录名 + * @param name 用户名称 + * @param password 用户密码 + * @param orgid 用户所在机构 ID + * @param session HTTP 会话对象 + * @return ModelAndView 对象,根据注册结果进行重定向或返回注册页面 + */ + // 映射 /PubRegistCommit 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/PubRegistCommit") + public ModelAndView registSubmit(String photoid, String loginname, String name, String password, String orgid, + HttpSession session) { + // 创建一个新的用户对象 + User user = new User(); + try { + // 从配置参数中获取系统是否允许注册的配置信息 + if (FarmParameterService.getInstance().getParameter("config.sys.registable ").equals("false")) { + // 如果不允许注册,抛出运行时异常 + throw new RuntimeException("该操作已经被管理员禁用!"); + } + // 设置用户头像 ID + user.setImgid(photoid); + // 设置用户登录名 + user.setLoginname(loginname); + // 设置用户名称 + user.setName(name); + // 设置用户状态为 "1" + user.setState("1"); + // 设置用户类型为 "1" + user.setType("1"); + // 调用用户服务的注册方法进行用户注册 + user = userServiceImpl.registUser(user, orgid); + // 调用用户服务的修改登录密码方法,将默认密码修改为用户输入的密码 + userServiceImpl.editLoginPassword(loginname, + FarmParameterService.getInstance().getParameter("config.default.password"), password); + + } catch (Exception e) { + // 打印异常堆栈信息 + e.printStackTrace(); + // 如果头像 ID 为空 + if (photoid == null || photoid.isEmpty()) { + // 设置默认头像 ID + photoid = "402888ac501d764801501d817b9e0011"; + } + // 将头像 ID、登录名、名称、机构 ID、头像 URL、错误信息放入视图模型,并返回用户注册页面的视图模型 + return ViewMode.getInstance() + .putAttr("photoid", photoid) + .putAttr("loginname", loginname) + .putAttr("name", name) + .putAttr("orgid", orgid) + .putAttr("imgUrl", DocumentConfig.getString("config.doc.download.url") + photoid) + .putAttr("errorMessage", e.getMessage()) + .returnModelAndView(ThemesUtil.getThemePath() + "/user/regist"); + } + // 将登录名和密码放入视图模型,并重定向到登录提交页面 + return ViewMode.getInstance() + .putAttr("name", loginname) + .putAttr("password", password) + .returnRedirectUrl("/login/websubmit.do"); + } + + /** + * 展示用户首页 + * + * @param userid 用户 ID + * @param type 展示类型,如 "know"、"file" 等 + * @param num 当前页码 + * @param session HTTP 会话对象 + * @return ModelAndView 对象,包含用户信息、文档信息等,并返回用户首页的视图模型 + */ + // 映射 /PubHome 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/PubHome") + public ModelAndView showUserHome(String userid, String type, Integer num, HttpSession session) { + // 创建视图模式实例 + ViewMode mode = ViewMode.getInstance(); + // 初始化是否是当前用户自己的标志为 false + boolean isSelf = false; + // 初始化数据查询结果对象为 null + DataResult result = null; + // 初始化用户对象为 null + User user = null; + try { + // 如果用户 ID 为空 + if (userid == null) { + // 根据当前用户的 ID 获取用户实体 + user = userServiceImpl.getUserEntity(getCurrentUser(session).getId()); + // 将是否是当前用户自己的标志放入视图模型 + mode.putAttr("self", true); + // 设置是否是当前用户自己的标志为 true + isSelf = true; + } else { + // 根据传入的用户 ID 获取用户实体 + user = userServiceImpl.getUserEntity(userid); + // 判断当前用户是否和传入的用户是同一用户 + if (user.getId().equals(getCurrentUser(session) != null ? getCurrentUser(session).getId() : "none")) { + // 如果是同一用户,将是否是当前用户自己的标志放入视图模型 + mode.putAttr("self", true); + // 设置是否是当前用户自己的标志为 true + isSelf = true; + } else { + // 如果不是同一用户,将是否是当前用户自己的标志放入视图模型 + mode.putAttr("self", false); + // 设置是否是当前用户自己的标志为 false + isSelf = false; + } + } + // 如果用户不为空 + if (user != null) { + // 根据用户信息获取用户统计数据 + DataResult users = farmDocRunInfoImpl.getStatUser(user); + // 如果用户有头像 ID + if (user.getImgid() != null && user.getImgid().trim().length() > 0) { + // 根据头像 ID 获取头像 URL 并放入视图模型 + mode.putAttr("photourl", farmFileManagerImpl.getFileURL(user.getImgid())); + } + // 将用户统计数据放入视图模型 + mode.putAttr("users", users); + } + // --------------------------查询知识列表和小组------------------------------ + // 如果当前页码为空 + if (num == null) { + // 设置当前页码为 1 + num = 1; + } + // 如果展示类型为空 + if (type == null) { + // 设置展示类型为 "know" + type = "know"; + } + // 根据展示类型进行不同的查询操作 + if (type.equals("know")) { + // 发布知识 + // 如果是当前用户自己,查询用户发布的所有文档;否则查询用户公开的文档 + result = isSelf ? farmDocRunInfoImpl.userDocs(user.getId(), "1", 10, num) + : farmDocRunInfoImpl.userPubDocs(user.getId(), "1", 10, num); + } + if (type.equals("file")) { + // 发布资源 + // 如果是当前用户自己,查询用户发布的资源文档;否则查询用户公开的资源文档 + result = isSelf ? farmDocRunInfoImpl.userDocs(user.getId(), "5", 10, num) + : farmDocRunInfoImpl.userPubDocs(user.getId(), "5", 10, num); + } + if (type.equals("joy")) { + // 如果用户 ID 不为空 + if (userid != null && !userid.isEmpty()) { + // 我的关注 + // 查询用户关注的文档,并设置当前页码和每页数量,然后执行查询 + result = farmDocRunInfoImpl.getUserEnjoyDoc(user.getId()).setCurrentPage(num).setPagesize(10) + .search(); + // 格式化文档发布时间 + result.runformatTime("PUBTIME", "yyyy-MM-dd HH:mm"); + } + } + if (type.equals("group")) { + // 加入小组 + // 根据用户 ID 查询用户加入的小组,并设置每页数量和当前页码 + result = farmDocgroupManagerImpl.getGroupsByUser(user.getId(), 16, num); + } + if (type.equals("audit")) { + // 审核中 + // 查询用户正在审核中的文档,并设置每页数量和当前页码 + result = farmDocRunInfoImpl.getMyAuditingByUser(user.getId(), 10, num); + // 格式化文档发布时间 + result.runformatTime("PUBTIME", "yyyy-MM-dd HH:mm"); + // 对文档状态进行字典转换 + result.runDictionary("1:待审核,2:审核通过,3:审核未通过,4:废弃", "STATE"); + } + if (type.equals("audito")) { + // 审核任务 + // 查询用户的审核任务文档,并设置每页数量和当前页码 + result = farmDocRunInfoImpl.getAuditDocByUser(user.getId(), 10, num); + // 格式化文档发布时间 + result.runformatTime("PUBTIME", "yyyy-MM-dd HH:mm"); + // 对文档状态进行字典转换 + result.runDictionary("1:待审核,2:审核通过,3:审核未通过,4:废弃", "STATE"); + } + if (type.equals("audith")) { + // 审核历史 + // 查询用户的审核历史文档,并设置每页数量和当前页码 + result = farmDocRunInfoImpl.getMyAuditedByUser(user.getId(), 10, num); + // 格式化文档发布时间 + result.runformatTime("PUBTIME", "yyyy-MM-dd HH:mm"); + // 对文档状态进行字典转换 + result.runDictionary("1:待审核,2:审核通过,3:审核未通过,4:废弃", "STATE"); + } + if (type.equals("usermessage")) { + // 用户消息 + // 根据当前用户的 ID 查询用户消息,并设置每页数量和当前页码 + result = usermessageServiceImpl.getMyMessageByUser(getCurrentUser(session).getId(), 10, num); + // 格式化用户消息创建时间 + result.runformatTime("USERMESSAGECTIME", "yyyy-MM-dd HH:mm"); + // 对消息阅读状态进行字典转换 + result.runDictionary("0:未读,1:已读", "READSTATE"); + } + } catch (SQLException e) { + // 如果发生 SQL 异常,设置错误信息并返回错误页面的视图模型 + return mode.setError(e.toString()).returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + // 将文档查询结果、用户 ID、展示类型、当前页码放入视图模型,并返回用户首页的视图模型 + return mode.putAttr("docs", result) + .putAttr("userid", user.getId()) + .putAttr("type", type) + .putAttr("num", num) + .returnModelAndView(ThemesUtil.getThemePath() + "/user/userHome"); + } + + /** + * 更新当前登录用户的信息 + * + * @param name 用户名称 + * @param photoid 用户头像 ID + * @param orgid 用户所在机构 ID + * @param session HTTP 会话对象 + * @return ModelAndView 对象,根据更新结果进行重定向或返回错误页面 + */ + // 映射 /editCurrentUser 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/editCurrentUser") + public ModelAndView editCurrentUser(String name, String photoid, String orgid, HttpSession session) { + try { + // 调用用户服务的修改当前用户信息方法 + userServiceImpl.editCurrentUser(getCurrentUser(session).getId(), name, photoid, orgid); + } catch (Exception e) { + // 打印异常堆栈信息 + e.printStackTrace(); + // 如果发生异常,设置错误信息并返回错误页面的视图模型 + return ViewMode.getInstance().setError(e.toString()).returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + // 重定向到用户首页 + return ViewMode.getInstance().returnRedirectUrl("/webuser/PubHome.do"); + } + + /** + * 跳转到修改密码页面 + * + * @return ModelAndView 对象,返回修改密码页面的视图模型 + */ + // 映射 /editCurrentUserPwd 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/editCurrentUserPwd") + public ModelAndView editCurrentUserPwd() { + // 返回修改密码页面的视图模型 + return ViewMode.getInstance().returnModelAndView(ThemesUtil.getThemePath() + "/user/passwordEdit"); + } + + /** + * 更新当前登录用户的密码 + * + * @param password 当前密码 + * @param newPassword 新密码 + * @param session HTTP 会话对象 + * @return ModelAndView 对象,根据更新结果进行重定向或返回错误页面 + */ + // 映射 /editCurrentUserPwdCommit 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/editCurrentUserPwdCommit") + public ModelAndView editCurrentUserPwdCommit(String password, String newPassword, HttpSession session) { + try { + // 调用用户服务的修改当前用户密码方法 + userServiceImpl.editCurrentUserPwdCommit(getCurrentUser(session).getId(), password, newPassword); + } catch (Exception e) { + // 打印异常堆栈信息 + e.printStackTrace(); + // 如果发生异常,设置错误信息并返回错误页面的视图模型 + return ViewMode.getInstance().setError(e.toString()).returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + // 重定向到用户首页 + return ViewMode.getInstance().returnRedirectUrl("/webuser/PubHome.do"); + } + + /** + * 验证当前用户密码的有效性 + * + * @param password 用户输入的密码 + * @param session HTTP 会话对象 + * @return Map 包含验证结果的键值对 + */ + // 映射 /validCurrUserPwd 请求路径,处理默认请求(未指定方法,通常为 GET),并将返回值直接作为响应体 + @RequestMapping("/validCurrUserPwd") + @ResponseBody + public Map validCurrUserPwd(String password, HttpSession session) { + try { + // 调用用户服务的验证当前用户密码方法 + boolean b = userServiceImpl.validCurrentUserPwd(getCurrentUser(session).getId(), password); + // 将验证结果放入视图模型并返回 + return ViewMode.getInstance().putAttr("validCurrentUserPwd", b).returnObjMode(); + } catch (Exception e) { + // 打印异常堆栈信息 + e.printStackTrace(); + // 如果发生异常,设置错误信息并返回包含错误信息的视图模型 + return ViewMode.getInstance().setError(e.toString()).returnObjMode(); + } + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/wcp/controller/UserMessageController.java b/src/wcp-web/src/main/java/com/farm/wcp/controller/UserMessageController.java new file mode 100644 index 0000000..8653681 --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/controller/UserMessageController.java @@ -0,0 +1,137 @@ +// 声明包路径 +package com.farm.wcp.controller; + +// 导入 List 接口,用于处理有序集合 +import java.util.List; +// 导入 Map 接口,用于处理键值对集合 +import java.util.Map; + +// 导入 Resource 注解,用于依赖注入 +import javax.annotation.Resource; +// 导入 HttpSession 类,用于管理用户会话 +import javax.servlet.http.HttpSession; + +// 导入 Controller 注解,将该类标记为 Spring MVC 的控制器 +import org.springframework.stereotype.Controller; +// 导入 RequestMapping 注解,用于映射请求路径 +import org.springframework.web.bind.annotation.RequestMapping; +// 导入 ResponseBody 注解,用于将方法的返回值直接写入响应体 +import org.springframework.web.bind.annotation.ResponseBody; +// 导入 ModelAndView 类,用于封装模型数据和视图信息 +import org.springframework.web.servlet.ModelAndView; + +// 导入 ViewMode 类,用于处理视图模式相关操作 +import com.farm.core.page.ViewMode; +// 导入 DBRule 类,用于数据库查询规则 +import com.farm.core.sql.query.DBRule; +// 导入 DBSort 类,用于数据库查询排序 +import com.farm.core.sql.query.DBSort; +// 导入 DataQuery 类,用于数据库查询操作 +import com.farm.core.sql.query.DataQuery; +// 导入 Usermessage 类,用于表示用户消息实体 +import com.farm.doc.domain.Usermessage; +// 导入 UsermessageServiceInter 接口,用于用户消息服务相关操作 +import com.farm.doc.server.UsermessageServiceInter; +// 导入 FarmDocmessageManagerInter 接口,用于文档消息管理相关操作 +import com.farm.doc.server.FarmDocmessageManagerInter; +// 导入 ThemesUtil 类,用于处理主题相关的工具方法 +import com.farm.wcp.util.ThemesUtil; +// 导入 WebUtils 类,可能包含一些通用的 Web 工具方法 +import com.farm.web.WebUtils; + +/** + * 用户消息相关的控制器类 + * + * @author autoCode + */ +// 定义请求映射的根路径为 /webusermessage +@RequestMapping("/webusermessage") +// 将该类标记为 Spring MVC 的控制器 +@Controller +// 继承 WebUtils 类,以获取其中的工具方法 +public class UserMessageController extends WebUtils { + // 注入 UsermessageServiceInter 接口的实现类实例,用于用户消息服务 + @Resource + private UsermessageServiceInter usermessageServiceImpl; + // 注入 FarmDocmessageManagerInter 接口的实现类实例,用于文档消息管理 + @Resource + private FarmDocmessageManagerInter farmDocmessageManagerImpl; + + /** + * 查看用户消息详情 + * + * @param id 消息 ID + * @param num 分页参数(返回消息列表需要) + * @return ModelAndView 对象,包含用户消息信息、消息阅读状态名称和分页参数,以及对应的视图 + */ + // 映射 /showMessage 请求路径,处理默认请求(未指定方法,通常为 GET) + @RequestMapping("/showMessage") + public ModelAndView showMessage(String id, Integer num) { + try { + // 将指定 ID 的用户消息标记为已读 + usermessageServiceImpl.setRead(id); + // 根据消息 ID 获取用户消息实体 + Usermessage usermessage = usermessageServiceImpl.getUsermessageEntity(id); + // 消息阅读状态名称 + String readstatename = ""; + // 判断消息的阅读状态 + if (usermessage.getReadstate().equals("0")) { // 0 表示未读、1 表示已读 + readstatename = "未读"; + } else if (usermessage.getReadstate().equals("1")) { + readstatename = "已读"; + } + // 将用户消息实体、消息阅读状态名称、分页参数放入视图模型,并返回用户消息详情页面的视图模型 + return ViewMode.getInstance() + .putAttr("usermessage", usermessage) + .putAttr("readstatename", readstatename) + .putAttr("num", num) + .returnModelAndView(ThemesUtil.getThemePath() + "/know/userMessage"); + } catch (Exception e) { + // 如果发生异常,设置错误信息并返回空的视图模型 + return ViewMode.getInstance().setError(e.toString()).returnModelAndView(""); + } + } + + /** + * 显示首页未读消息 + * + * @param session HTTP 会话对象 + * @return Map 包含新消息标题和未读消息数量的键值对 + */ + // 映射 /showHomeMessage 请求路径,处理默认请求(未指定方法,通常为 GET),并将返回值作为响应体返回 + @RequestMapping("/showHomeMessage") + @ResponseBody + public Map showHomeMessage(HttpSession session) { + try { + // 创建一个用户消息的简单查询对象 + DataQuery query = usermessageServiceImpl.createUsermessageSimpleQuery(null); + // 设置不进行计数查询(可能是为了只获取数据而不统计总数) + query.setNoCount(); + // 添加查询规则,筛选出当前用户为接收者的消息 + query.addRule(new DBRule("USERMESSAGE.READUSERID", getCurrentUser(session).getId(), "=")); + // 添加查询规则,筛选出未读的消息 + query.addRule(new DBRule("USERMESSAGE.READSTATE", "0", "=")); + // 添加排序规则,按消息创建时间降序排列 + query.addSort(new DBSort("USERMESSAGE.CTIME", "DESC")); + // 执行查询并获取结果列表 + List> list = query.search().getResultList(); + // 新消息标题 + String newMessage = ""; + // 如果结果列表不为空且有数据 + if (list != null && list.size() > 0) { + // 获取第一条消息的标题 + newMessage = list.get(0).get("TITLE").toString(); + } + + // 将新消息标题和未读消息数量放入视图模型,并返回对应的键值对 + return ViewMode.getInstance() + .putAttr("newMessage", newMessage) + .putAttr("unReadCount", list.size()) + .returnObjMode(); + } catch (Exception e) { + // 如果发生异常,返回空的视图模型对应的键值对 + return ViewMode.getInstance().returnObjMode(); + } + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/wcp/controller/WebFileController.java b/src/wcp-web/src/main/java/com/farm/wcp/controller/WebFileController.java new file mode 100644 index 0000000..d022c95 --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/controller/WebFileController.java @@ -0,0 +1,333 @@ +// 声明包路径 +package com.farm.wcp.controller; + +// 导入 ArrayList 类,用于创建动态数组 +import java.util.ArrayList; +// 导入 Arrays 类,提供了操作数组的静态方法 +import java.util.Arrays; +// 导入 HashMap 类,用于存储键值对 +import java.util.HashMap; +// 导入 List 接口,用于表示有序集合 +import java.util.List; +// 导入 Map 接口,用于表示键值对映射 +import java.util.Map; + +// 导入 Resource 注解,用于依赖注入 +import javax.annotation.Resource; +// 导入 HttpServletRequest 类,用于处理 HTTP 请求 +import javax.servlet.http.HttpServletRequest; +// 导入 HttpSession 类,用于管理用户会话 +import javax.servlet.http.HttpSession; + +// 导入 Controller 注解,将该类标记为 Spring MVC 控制器 +import org.springframework.stereotype.Controller; +// 导入 RequestMapping 注解,用于映射请求路径 +import org.springframework.web.bind.annotation.RequestMapping; +// 导入 ModelAndView 类,用于封装模型数据和视图信息 +import org.springframework.web.servlet.ModelAndView; + +// 导入 UserServiceInter 接口,提供用户服务相关功能 +import com.farm.authority.service.UserServiceInter; +// 导入 ViewMode 类,用于构建视图模型 +import com.farm.core.page.ViewMode; +// 导入 Doc 类,代表文档实体 +import com.farm.doc.domain.Doc; +// 导入 FarmDoctype 类,代表文档类型实体 +import com.farm.doc.domain.FarmDoctype; +// 导入 DocEntire 类,代表完整的文档实体 +import com.farm.doc.domain.ex.DocEntire; +// 导入 TypeBrief 类,代表文档类型的简要信息 +import com.farm.doc.domain.ex.TypeBrief; +// 导入 FarmDocManagerInter 接口,提供文档管理相关功能 +import com.farm.doc.server.FarmDocManagerInter; +// 导入 FarmDocOperateRightInter 接口,提供文档操作权限相关功能 +import com.farm.doc.server.FarmDocOperateRightInter; +// 导入 POP_TYPE 枚举,定义文档操作权限类型 +import com.farm.doc.server.FarmDocOperateRightInter.POP_TYPE; +// 导入 FarmParameterService 类,用于获取系统参数 +import com.farm.parameter.FarmParameterService; +// 导入 FarmDocRunInfoInter 接口,提供文档运行信息相关功能 +import com.farm.doc.server.FarmDocRunInfoInter; +// 导入 FarmDocTypeInter 接口,提供文档类型管理相关功能 +import com.farm.doc.server.FarmDocTypeInter; +// 导入 FarmDocgroupManagerInter 接口,提供文档组管理相关功能 +import com.farm.doc.server.FarmDocgroupManagerInter; +// 导入 FarmDocmessageManagerInter 接口,提供文档消息管理相关功能 +import com.farm.doc.server.FarmDocmessageManagerInter; +// 导入 FarmFileManagerInter 接口,提供文件管理相关功能 +import com.farm.doc.server.FarmFileManagerInter; +// 导入 ThemesUtil 类,用于处理主题相关的工具方法 +import com.farm.wcp.util.ThemesUtil; +// 导入 WcpWebFileManagerInter 接口,提供 WCP 网页文件管理相关功能 +import com.farm.wcp.webfile.server.WcpWebFileManagerInter; +// 导入 WebUtils 类,提供通用的 Web 工具方法 +import com.farm.web.WebUtils; + +/** + * 该控制器类主要处理资源文件相关的操作,如创建、编辑等 + * + * @author autoCode + */ +// 定义请求映射的根路径为 /webfile +@RequestMapping("/webfile") +// 将该类标记为 Spring MVC 控制器 +@Controller +// 继承 WebUtils 类,获取通用的 Web 工具方法 +public class WebFileController extends WebUtils { + // 注入 FarmDocgroupManagerInter 接口的实现类实例,用于文档组管理 + @Resource + private FarmDocgroupManagerInter farmDocgroupManagerImpl; + // 注入 FarmFileManagerInter 接口的实现类实例,用于文件管理 + @Resource + private FarmFileManagerInter farmFileManagerImpl; + // 注入 FarmDocManagerInter 接口的实现类实例,用于文档管理 + @Resource + private FarmDocManagerInter farmDocManagerImpl; + // 注入 FarmDocRunInfoInter 接口的实现类实例,用于文档运行信息管理 + @Resource + private FarmDocRunInfoInter farmDocRunInfoImpl; + // 注入 FarmDocmessageManagerInter 接口的实现类实例,用于文档消息管理 + @Resource + private FarmDocmessageManagerInter farmDocmessageManagerImpl; + // 注入 FarmDocOperateRightInter 接口的实现类实例,用于文档操作权限管理 + @Resource + private FarmDocOperateRightInter farmDocOperateRightImpl; + // 注入 UserServiceInter 接口的实现类实例,用于用户服务 + @Resource + private UserServiceInter userServiceImpl; + // 注入 WcpWebFileManagerInter 接口的实现类实例,用于 WCP 网页文件管理 + @Resource + private WcpWebFileManagerInter wcpWebFileManagerImpl; + // 注入 FarmDocTypeInter 接口的实现类实例,用于文档类型管理 + @Resource + private FarmDocTypeInter farmDocTypeManagerImpl; + + /** + * 进入创建资源文件页面 + * + * @param typeid 文档类型 ID + * @param groupid 文档组 ID + * @param session HTTP 会话对象 + * @return 包含相关数据的视图模型 + */ + // 映射 /add 请求路径,处理默认请求(通常为 GET) + @RequestMapping("/add") + public ModelAndView creatWebFile(String typeid, String groupid, HttpSession session) { + // 创建一个新的完整文档实体,初始化为空文档 + DocEntire doc = new DocEntire(new Doc()); + // 如果传入的文档类型 ID 不为空且不是 "NONE" + if (typeid != null && !typeid.toUpperCase().trim().equals("NONE") && !typeid.toUpperCase().trim().equals("")) { + // 根据文档类型 ID 获取文档类型实体 + FarmDoctype doctype = farmDocTypeManagerImpl.getType(typeid); + // 设置文档的类型 + doc.setType(doctype); + } + // 如果传入的文档组 ID 不为空且不是 "NONE" + if (groupid != null && !groupid.toUpperCase().trim().equals("NONE") + && !groupid.toUpperCase().trim().equals("")) { + // 设置文档所属的文档组 ID + doc.getDoc().setDocgroupid(groupid); + } + // 获取当前用户可用于撰写文档的文档类型列表 + List types = farmDocTypeManagerImpl.getTypesForWriteDoc(getCurrentUser(session)); + // 从系统参数中获取允许上传的文件类型字符串,并转换为小写,替换中文逗号为英文逗号 + String filetypeString = FarmParameterService.getInstance().getParameter("config.doc.upload.types").toLowerCase() + .replaceAll(",", ","); + // 用于存储处理后的文件类型字符串 + StringBuffer filetypestrplus = new StringBuffer(); + // 遍历解析后的文件类型字符串 + for (String node : parseIds(filetypeString)) { + // 如果不是第一个文件类型,添加分隔符 + if (filetypestrplus.length() > 0) { + filetypestrplus.append(";"); + } + // 拼接文件类型字符串 + filetypestrplus.append("*." + node); + } + // 将文档类型列表、文档实体、文件类型字符串等数据放入视图模型,并返回创建资源文件页面的视图模型 + return ViewMode.getInstance().putAttr("types", types).putAttr("doc", doc).putAttr("filetypestr", filetypeString) + .putAttr("filetypestrplus", filetypestrplus.toString()) + .returnModelAndView(ThemesUtil.getThemePath() + "/webfile/creat"); + } + + /** + * 进入编辑资源文件页面 + * + * @param docId 文档 ID + * @param session HTTP 会话对象 + * @param request HTTP 请求对象 + * @return 包含相关数据的视图模型 + */ + // 映射 /edit 请求路径,处理默认请求(通常为 GET) + @RequestMapping("/edit") + public ModelAndView editWebfile(String docId, HttpSession session, HttpServletRequest request) { + // 定义完整的文档实体对象 + DocEntire doc = null; + try { + // 根据文档 ID 和当前用户获取完整的文档实体 + doc = farmDocManagerImpl.getDoc(docId, getCurrentUser(session)); + } catch (Exception e) { + // 如果发生异常,设置错误信息并返回错误页面的视图模型 + return ViewMode.getInstance().setError(e.toString()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + // 获取当前用户可用于撰写文档的文档类型列表 + List types = farmDocTypeManagerImpl.getTypesForWriteDoc(getCurrentUser(session)); + // 从系统参数中获取允许上传的文件类型字符串,并转换为小写,替换中文逗号为英文逗号 + String filetypeString = FarmParameterService.getInstance().getParameter("config.doc.upload.types").toLowerCase() + .replaceAll(",", ","); + // 用于存储处理后的文件类型字符串 + StringBuffer filetypestrplus = new StringBuffer(); + // 遍历解析后的文件类型字符串 + for (String node : parseIds(filetypeString)) { + // 如果不是第一个文件类型,添加分隔符 + if (filetypestrplus.length() > 0) { + filetypestrplus.append(";"); + } + // 拼接文件类型字符串 + filetypestrplus.append("*." + node); + } + // 将完整的文档实体、文档类型列表、文件类型字符串等数据放入视图模型,并返回编辑资源文件页面的视图模型 + return ViewMode.getInstance().putAttr("doce", doc).putAttr("types", types) + .putAttr("filetypestr", filetypeString).putAttr("filetypestrplus", filetypestrplus.toString()) + .returnModelAndView(ThemesUtil.getThemePath() + "/webfile/edit"); + } + + /** + * 提交编辑资源文件的表单 + * + * @param docid 文档 ID + * @param fileId 文件 ID + * @param knowtype 知识类型 + * @param knowtitle 知识标题 + * @param knowtag 知识标签 + * @param docgroup 文档组 + * @param writetype 写入权限类型 + * @param readtype 读取权限类型 + * @param text 文档内容 + * @param editnote 编辑备注 + * @param session HTTP 会话对象 + * @return 重定向或错误页面的视图模型 + */ + // 映射 /editCommit 请求路径,处理默认请求(通常为 POST) + @RequestMapping("/editCommit") + public ModelAndView editCommit(String docid, String fileId, String knowtype, String knowtitle, String knowtag, + String docgroup, String writetype, String readtype, String text, String editnote, HttpSession session) { + // 定义完整的文档实体对象 + DocEntire doc = null; + try { + // 如果文档组 ID 为 "0",将其置为 null + if (docgroup.equals("0")) { + docgroup = null; + } + // 调用 WCP 网页文件管理服务的编辑方法,更新文档信息 + doc = wcpWebFileManagerImpl.editWebFile(docid, Arrays.asList(fileId.trim().split(",")), knowtype, knowtitle, + knowtag, docgroup, text, POP_TYPE.getEnum(writetype), POP_TYPE.getEnum(readtype), editnote, + getCurrentUser(session)); + // 如果文档需要审核 + if (doc.getAudit() != null) { + // 重定向到审核临时文档页面 + return ViewMode.getInstance().returnRedirectUrl("/audit/tempdoc.do?auditid=" + doc.getAudit().getId()); + } + // 重定向到文档查看页面 + return ViewMode.getInstance().returnRedirectUrl("/webdoc/view/Pub" + doc.getDoc().getId() + ".html"); + } catch (Exception e) { + // 打印异常堆栈信息 + e.printStackTrace(); + // 设置错误信息并返回错误页面的视图模型 + return ViewMode.getInstance().setError(e.toString()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + } + + /** + * 提交创建资源文件的表单 + * + * @param fileId 文件 ID + * @param creattype 创建类型 + * @param knowtype 知识类型 + * @param knowtitle 知识标题 + * @param knowtag 知识标签 + * @param text 文档内容 + * @param docgroup 文档组 + * @param writetype 写入权限类型 + * @param readtype 读取权限类型 + * @param session HTTP 会话对象 + * @return 包含消息或重定向的视图模型 + */ + // 映射 /addsubmit 请求路径,处理默认请求(通常为 POST) + @RequestMapping("/addsubmit") + public ModelAndView creatWebFileSubmit(String fileId, String creattype, String knowtype, String knowtitle, + String knowtag, String text, String docgroup, String writetype, String readtype, HttpSession session) { + try { + // 将文件 ID 字符串按逗号分割并转换为列表 + List fileids = Arrays.asList(fileId.trim().split(",")); + // 如果创建类型为 "on" + if (creattype != null && creattype.equals("on")) { + /** 创建为独立知识 **/ + // 用于存储文件名和对应文档链接的映射 + Map doclinks = new HashMap(); + // 定义完整的文档实体对象 + DocEntire doc = null; + // 遍历文件 ID 列表 + for (String fileid : fileids) { + // 创建一个只包含当前文件 ID 的列表 + List fileidlist = new ArrayList<>(); + fileidlist.add(fileid); + // 如果文档组 ID 为 "0",将其置为 null + if (docgroup != null && docgroup.equals("0")) { + docgroup = null; + } + // 根据文件 ID 获取文件名 + String fileName = farmFileManagerImpl.getFile(fileid).getName(); + // 调用 WCP 网页文件管理服务的创建方法,创建文档 + doc = wcpWebFileManagerImpl.creatWebFile(fileidlist, knowtype, fileName, knowtag, docgroup, text, + POP_TYPE.getEnum(writetype), POP_TYPE.getEnum(readtype), getCurrentUser(session)); + // 如果文档需要审核 + if (doc.getAudit() != null) { + // 将文件名和审核临时文档链接存入映射 + doclinks.put(fileName, "/audit/tempdoc.do?auditid=" + doc.getAudit().getId()); + } else { + // 将文件名和文档查看链接存入映射 + doclinks.put(fileName, "/webdoc/view/Pub" + doc.getDoc().getId() + ".html"); + } + } + // 如果最后一个文档需要审核 + if (doc.getAudit() != null) { + // 设置消息并返回消息页面的视图模型 + return ViewMode.getInstance().putAttr("MESSAGE", fileids.size() + "个资源文件创建成功,但是需要审核后才能够被他人访问!") + .returnModelAndView(ThemesUtil.getThemePath() + "/message"); + } + // 设置消息和文档链接映射,并返回消息页面的视图模型 + return ViewMode.getInstance().putAttr("MESSAGE", fileids.size() + "个资源文件创建成功!") + .putAttr("LINKS", doclinks).returnModelAndView(ThemesUtil.getThemePath() + "/message"); + } else { + /** 创建为一个知识 **/ + // 定义完整的文档实体对象 + DocEntire doc = null; + // 如果文档组 ID 为 "0",将其置为 null + if (docgroup.equals("0")) { + docgroup = null; + } + // 调用 WCP 网页文件管理服务的创建方法,创建文档 + doc = wcpWebFileManagerImpl.creatWebFile(fileids, knowtype, knowtitle, knowtag, docgroup, text, + POP_TYPE.getEnum(writetype), POP_TYPE.getEnum(readtype), getCurrentUser(session)); + // 如果文档需要审核 + if (doc.getAudit() != null) { + // 重定向到审核临时文档页面 + return ViewMode.getInstance() + .returnRedirectUrl("/audit/tempdoc.do?auditid=" + doc.getAudit().getId()); + } + // 重定向到文档查看页面 + return ViewMode.getInstance().returnRedirectUrl("/webdoc/view/Pub" + doc.getDoc().getId() + ".html"); + } + } catch (Exception e) { + // 打印异常堆栈信息 + e.printStackTrace(); + // 设置错误信息并返回错误页面的视图模型 + return ViewMode.getInstance().setError(e.toString()) + .returnModelAndView(ThemesUtil.getThemePath() + "/error"); + } + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/wcp/util/HttpDocument.java b/src/wcp-web/src/main/java/com/farm/wcp/util/HttpDocument.java new file mode 100644 index 0000000..05087e4 --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/util/HttpDocument.java @@ -0,0 +1,206 @@ +// 声明包路径 +package com.farm.wcp.util; + +// 导入 File 类,用于操作文件 +import java.io.File; +// 导入 IOException 异常类,用于处理输入输出异常 +import java.io.IOException; +// 导入 URL 类,用于表示统一资源定位符 +import java.net.URL; +// 导入 Iterator 接口,用于遍历集合元素 +import java.util.Iterator; + +// 导入 Jsoup 的 Connection 类,用于建立 HTTP 连接 +import org.jsoup.Connection; +// 导入 Jsoup 类,用于解析 HTML 文档 +import org.jsoup.Jsoup; +// 导入 Jsoup 的 Document 类,用于表示 HTML 文档 +import org.jsoup.nodes.Document; +// 导入 Jsoup 的 Element 类,用于表示 HTML 元素 +import org.jsoup.nodes.Element; +// 导入 Jsoup 的 Elements 类,用于表示 HTML 元素集合 +import org.jsoup.select.Elements; + +// 导入 HttpResourceHandle 类,用于处理 HTTP 资源 +import com.farm.wcp.know.util.HttpResourceHandle; + +/** + * 该类用于配合 Jsoup 处理 HTML 文档,提供了对 HTML 文档的各种操作方法 + * + * @author Administrator + */ +public class HttpDocument { + // 存储 Jsoup 的 Document 对象,代表 HTML 文档 + private Document document; + // 存储 HTML 文档的 URL + private URL url; + // 定义请求时使用的用户代理字符串,模拟 Firefox 浏览器 + private static final String AGENT_REQUEST = "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko/20100101 Firefox/11.0"; + + /** + * 获取 Jsoup 的 Document 对象 + * + * @return 返回存储的 Document 对象 + */ + public Document getDocument() { + return document; + } + + // 私有构造函数,防止外部直接实例化 + private HttpDocument() { + + } + + /** + * 删除和内容无关的标签(保留图片) + * 包括 script、textarea、style、a 的 href 属性、input、button、iframe 标签 + */ + public void removeOutContent() { + // 删除所有 script 标签 + document.getElementsByTag("script").remove(); + // 删除所有 textarea 标签 + document.getElementsByTag("textarea ").remove(); + // 删除所有 style 标签 + document.getElementsByTag("style").remove(); + // 移除所有 a 标签的 href 属性 + document.getElementsByTag("a").removeAttr("href"); + // 删除所有 input 标签 + document.getElementsByTag("input").remove(); + // 删除所有 button 标签 + document.getElementsByTag("button").remove(); + // 注释掉的代码,原本用于移除 img 标签的 src 属性 + // document.getElementsByTag("img").removeAttr("src"); + // 删除所有 iframe 标签 + document.getElementsByTag("iframe").remove(); + } + + /** + * 改写页面中的远程资源 + * + * @param handle 用于处理 HTTP 资源的对象 + */ + public void rewriteResources(HttpResourceHandle handle) { + // 获取文档中所有的 img 标签 + Elements imgs = document.getElementsByTag("img"); + // 获取 img 标签集合的迭代器 + Iterator imgtor = imgs.iterator(); + // 遍历所有 img 标签 + while (imgtor.hasNext()) { + // 获取当前的 img 标签元素 + Element element = imgtor.next(); + // 存储图片的原始 URL + String url = null; + // 存储处理后的图片 URL + String rUrl = null; + // 获取 img 标签的 src 属性值 + url = element.attr("src"); + // 如果 src 属性值不为空 + if (url != null && url.trim().length() > 0) { + // 调用 handle 方法处理图片 URL + rUrl = handle.handle(url, this.url); + // 将 img 标签的 src 属性替换为处理后的 URL + element.attr("src", rUrl); + } + } + } + + /** + * 获得 HTML 的字符集 + * + * @return 返回 HTML 文档的字符集,如果未找到则默认返回 UTF-8 + */ + public String getCharset() { + // 获取文档中所有的 meta 标签 + Elements meta = document.getElementsByTag("meta"); + // 遍历所有 meta 标签 + for (Element node : meta) { + // 获取 meta 标签的 content 属性值 + String attr = node.attr("content"); + // 如果 content 属性值中包含 "CHARSET" + if (attr.toUpperCase().indexOf("CHARSET") >= 0) { + // 截取字符集部分 + String charstr = attr.toUpperCase().substring( + attr.toUpperCase().indexOf("=") + 1); + // 返回字符集 + return charstr; + } + } + // 如果未找到字符集信息,默认返回 UTF-8 + return "UTF-8"; + } + + /** + * 获取 HTML 文档的标题 + * + * @return 返回 HTML 文档的标题文本 + */ + public String getTitle() { + // 获取文档中所有的 title 标签 + Elements Styles = document.getElementsByTag("title"); + // 返回第一个 title 标签的文本内容 + return Styles.get(0).text(); + } + + /** + * 根据 Jsoup 的 Connection 对象实例化 HttpDocument 对象 + * + * @param con Jsoup 的 Connection 对象 + * @return 返回实例化后的 HttpDocument 对象,如果发生异常则返回 null + */ + public static HttpDocument instance(Connection con) { + // 声明 HttpDocument 对象 + HttpDocument macDoc = null; + try { + // 创建 HttpDocument 对象 + macDoc = new HttpDocument(); + // 设置 HttpDocument 对象的 URL + macDoc.url = con.request().url(); + // 使用 Connection 对象发送请求并获取 Document 对象 + macDoc.document = con.userAgent(AGENT_REQUEST).get(); + } catch (IOException e) { + // 打印异常堆栈信息 + e.printStackTrace(); + } + // 返回实例化后的 HttpDocument 对象 + return macDoc; + } + + /** + * 根据 HTML 字符串实例化 HttpDocument 对象 + * + * @param html HTML 字符串 + * @return 返回实例化后的 HttpDocument 对象 + */ + public static HttpDocument instance(String html) { + // 声明 HttpDocument 对象 + HttpDocument macDoc = null; + // 创建 HttpDocument 对象 + macDoc = new HttpDocument(); + // 使用 Jsoup 解析 HTML 字符串并设置为 Document 对象 + macDoc.document = Jsoup.parseBodyFragment(html); + // 返回实例化后的 HttpDocument 对象 + return macDoc; + } + + /** + * 根据文件实例化 HttpDocument 对象 + * + * @param file 文件对象 + * @return 返回实例化后的 HttpDocument 对象,如果发生异常则返回 null + */ + public static HttpDocument instance(File file) { + // 声明 HttpDocument 对象 + HttpDocument macDoc = null; + try { + // 创建 HttpDocument 对象 + macDoc = new HttpDocument(); + // 使用 Jsoup 解析文件内容并设置为 Document 对象,指定字符集为 UTF-8 + macDoc.document = Jsoup.parse(file, "UTF-8"); + } catch (IOException e) { + // 打印异常堆栈信息 + e.printStackTrace(); + } + // 返回实例化后的 HttpDocument 对象 + return macDoc; + } +} diff --git a/src/wcp-web/src/main/java/com/farm/wcp/util/ImHistory.java b/src/wcp-web/src/main/java/com/farm/wcp/util/ImHistory.java new file mode 100644 index 0000000..5533d8e --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/util/ImHistory.java @@ -0,0 +1,81 @@ +// 声明包路径 +package com.farm.wcp.util; + +// 导入 ArrayList 类,用于创建动态数组 +import java.util.ArrayList; +// 导入 Date 类,用于处理日期和时间 +import java.util.Date; +// 导入 List 接口,用于表示有序集合 +import java.util.List; + +// 导入 HttpSession 类,用于管理用户会话 +import javax.servlet.http.HttpSession; + +// 导入 TimeTool 类,用于时间处理 +import com.farm.core.time.TimeTool; +// 导入 LlmMessage 类,用于表示聊天消息 +import com.farm.llm.utils.LlmMessage; +// 导入 M_TYPE 枚举,用于表示消息类型 +import com.farm.llm.utils.LlmMessage.M_TYPE; + +/** + * 该类用于管理聊天记录,提供了添加、清除和获取聊天记录的方法 + * + * @author macpl + */ +public class ImHistory { + // 定义会话属性名称,用于存储聊天记录列表 + private static String SESSION_IM_HIS = "SESSION_IM_HIS"; + + /** + * 向会话中添加聊天消息 + * + * @param session HTTP 会话对象 + * @param message HTML 格式的消息内容 + * @param rawMsg 原始消息内容 + * @param type 消息类型,取值为 M_TYPE 枚举中的值(mine|service) + */ + public static void addMessage(HttpSession session, String message, String rawMsg, M_TYPE type) { + // 从会话中获取聊天记录列表,进行类型转换 + @SuppressWarnings("unchecked") + List list = (List) session.getAttribute(SESSION_IM_HIS); + // 如果列表为空 + if (list == null) { + // 创建一个新的 ArrayList 作为聊天记录列表 + list = new ArrayList(); + } + // 创建一个新的 LlmMessage 对象,包含消息内容、消息类型和当前时间 + list.add(new LlmMessage(message, type, TimeTool.format(new Date(), "yyyy-MM-dd HH:mm"))); + // 将更新后的聊天记录列表存储到会话中 + session.setAttribute(SESSION_IM_HIS, list); + } + + /** + * 清除会话中的聊天记录 + * + * @param session HTTP 会话对象 + */ + public static void clearMessage(HttpSession session) { + // 将空的聊天记录列表存储到会话中,实现清除聊天记录的功能 + session.setAttribute(SESSION_IM_HIS, new ArrayList()); + } + + /** + * 从会话中获取聊天记录列表 + * + * @param session HTTP 会话对象 + * @return 聊天记录列表,如果会话中没有存储聊天记录,则返回一个空的列表 + */ + public static List getMessages(HttpSession session) { + // 从会话中获取聊天记录列表,进行类型转换 + @SuppressWarnings("unchecked") + List list = (List) session.getAttribute(SESSION_IM_HIS); + // 如果列表为空 + if (list == null) { + // 返回一个新的空的 ArrayList + return new ArrayList(); + } + // 返回获取到的聊天记录列表 + return list; + } +} diff --git a/src/wcp-web/src/main/java/com/farm/wcp/util/LuceneDocUtil.java b/src/wcp-web/src/main/java/com/farm/wcp/util/LuceneDocUtil.java new file mode 100644 index 0000000..0e55383 --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/util/LuceneDocUtil.java @@ -0,0 +1,82 @@ +// 声明该类所在的包 +package com.farm.wcp.util; + +// 导入 Lucene 中用于设置字段索引方式的 Index 类 +import org.apache.lucene.document.Field.Index; +// 导入 Lucene 中用于设置字段存储方式的 Store 类 +import org.apache.lucene.document.Field.Store; + +// 导入文档类型实体类,代表文档的类型信息 +import com.farm.doc.domain.FarmDoctype; +// 导入完整文档实体类,包含文档的各种信息 +import com.farm.doc.domain.ex.DocEntire; +// 导入 HTML 工具类,用于处理 HTML 文本 +import com.farm.doc.util.HtmlUtils; +// 导入自定义的 DocMap 类,用于存储文档的索引元数据 +import com.farm.lucene.adapter.DocMap; + +/** + * 该类的主要作用是在进行全文检索时,辅助生成索引元数据对象。 + * + * @author Administrator + */ +public class LuceneDocUtil { + /** + * 根据传入的完整文档实体对象生成一个用于全文检索的索引元数据对象。 + * + * @param doc 完整文档实体对象,包含了文档的各种详细信息 + * @return 生成的索引元数据对象,包含了文档的各个字段及其索引和存储设置 + */ + public static DocMap getDocMap(DocEntire doc) { + // 用于存储文档的文本内容 + String text = ""; + // 检查文档的文本信息是否存在 + if (doc.getTexts() != null) { + // 若存在,获取文档的第一个文本内容 + text = doc.getTexts().getText1(); + } + // 调用 HTML 工具类的方法,去除文本中的 HTML 标签 + text = HtmlUtils.HtmlRemoveTag(text); + // 创建一个 DocMap 对象,以文档的 ID 作为标识 + DocMap map = new DocMap(doc.getDoc().getId()); + // 用于存储拼接后的所有上级分类名称 + String typeAll = ""; + // 拼接所有上级分类名称,用于索引 + if (doc.getCurrenttypes() != null) { + // 遍历文档当前的所有类型 + for (FarmDoctype node : doc.getCurrenttypes()) { + // 判断是否是第一个类型 + if (typeAll.equals("")) { + // 若是第一个类型,直接赋值 + typeAll = node.getName(); + } else { + // 若不是第一个类型,用斜杠拼接类型名称 + typeAll = typeAll + "/" + node.getName(); + } + } + } + // 将拼接好的所有上级分类名称存入 DocMap 对象,设置为存储且可分析索引 + map.put("TYPENAME", typeAll, Store.YES, Index.ANALYZED); + // 将文档的标签关键字存入 DocMap 对象,设置为存储且可分析索引 + map.put("TAGKEY", doc.getDoc().getTagkey(), Store.YES, Index.ANALYZED); + // 将文档的标题存入 DocMap 对象,设置为存储且可分析索引 + map.put("TITLE", doc.getDoc().getTitle(), Store.YES, Index.ANALYZED); + // 将文档的作者存入 DocMap 对象,设置为存储且可分析索引 + map.put("AUTHOR", doc.getDoc().getAuthor(), Store.YES, Index.ANALYZED); + // 将文档的描述存入 DocMap 对象,设置为存储且可分析索引 + map.put("DOCDESCRIBE", doc.getDoc().getDocdescribe(), Store.YES, Index.ANALYZED); + // 将文档的访问次数存入 DocMap 对象,初始值设为 0,设置为存储且可分析索引 + map.put("VISITNUM", "0", Store.YES, Index.ANALYZED); + // 将文档的发布时间存入 DocMap 对象,设置为存储且可分析索引 + map.put("PUBTIME", doc.getDoc().getPubtime(), Store.YES, Index.ANALYZED); + // 将文档的创建用户 ID 存入 DocMap 对象,设置为存储且可分析索引 + map.put("USERID", doc.getDoc().getCuser(), Store.YES, Index.ANALYZED); + // 将文档的领域类型存入 DocMap 对象,设置为存储且可分析索引 + map.put("DOMTYPE", doc.getDoc().getDomtype(), Store.YES, Index.ANALYZED); + // 将处理后的文档文本内容存入 DocMap 对象,设置为存储且可分析索引 + map.put("TEXT", text, Store.YES, Index.ANALYZED); + // 返回生成好的 DocMap 对象 + return map; + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/wcp/util/ThemesUtil.java b/src/wcp-web/src/main/java/com/farm/wcp/util/ThemesUtil.java new file mode 100644 index 0000000..9d75c5b --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/util/ThemesUtil.java @@ -0,0 +1,24 @@ +// 声明类所在的包 +package com.farm.wcp.util; + +// 导入用于获取系统参数的服务类 +import com.farm.parameter.FarmParameterService; + +/** + * 该类提供了与主题路径相关的工具方法。 + */ +public class ThemesUtil { + /** + * 获取系统配置的主题路径。 + * + * 此方法通过调用 FarmParameterService 类的实例来获取名为 "config.sys.web.themes.path" 的系统参数值。 + * 该参数值代表了系统中配置的主题路径。 + * + * @return 系统配置的主题路径字符串。 + */ + public static String getThemePath() { + // 通过单例模式获取 FarmParameterService 实例,并调用其 getParameter 方法获取指定参数的值 + return FarmParameterService.getInstance().getParameter("config.sys.web.themes.path"); + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/wcp/util/ZxingTowDCode.java b/src/wcp-web/src/main/java/com/farm/wcp/util/ZxingTowDCode.java new file mode 100644 index 0000000..6145f53 --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/wcp/util/ZxingTowDCode.java @@ -0,0 +1,97 @@ +// 声明包路径 +package com.farm.wcp.util; + +// 导入 Google ZXing 库中用于表示二维码矩阵的类 +import com.google.zxing.common.BitMatrix; + +// 导入用于处理图像 I/O 的类 +import javax.imageio.ImageIO; +// 导入用于表示文件的类 +import java.io.File; +// 导入用于表示输出流的类 +import java.io.OutputStream; +// 导入用于处理 I/O 异常的类 +import java.io.IOException; +// 导入用于处理图像的类 +import java.awt.image.BufferedImage; + +/** + * 该类提供了将 ZXing 二维码矩阵转换为 BufferedImage 以及将其写入文件或输出流的工具方法。 + */ +public class ZxingTowDCode { + + // 定义黑色的 RGB 值 + private static final int BLACK = 0xFF000000; + // 定义白色的 RGB 值 + private static final int WHITE = 0xFFFFFFFF; + + // 私有构造函数,防止类被实例化 + private ZxingTowDCode() { + } + + /** + * 将 ZXing 的二维码矩阵转换为 BufferedImage 对象。 + * + * @param matrix 要转换的二维码矩阵 + * @return 转换后的 BufferedImage 对象 + */ + public static BufferedImage toBufferedImage(BitMatrix matrix) { + // 获取二维码矩阵的宽度 + int width = matrix.getWidth(); + // 获取二维码矩阵的高度 + int height = matrix.getHeight(); + // 创建一个新的 BufferedImage 对象,类型为 TYPE_INT_RGB + BufferedImage image = new BufferedImage(width, height, + BufferedImage.TYPE_INT_RGB); + // 遍历二维码矩阵的每一个像素点 + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y++) { + // 根据二维码矩阵中该点的值设置 BufferedImage 中对应点的 RGB 值 + image.setRGB(x, y, matrix.get(x, y) ? BLACK : WHITE); + } + } + // 返回转换后的 BufferedImage 对象 + return image; + } + + /** + * 将二维码矩阵转换为指定格式的图像并写入文件。 + * + * @param matrix 要转换和写入的二维码矩阵 + * @param format 图像格式,如 "png", "jpg" 等 + * @param file 要写入的文件对象 + * @throws IOException 如果写入过程中出现 I/O 错误 + */ + public static void writeToFile(BitMatrix matrix, String format, File file) + throws IOException { + // 将二维码矩阵转换为 BufferedImage 对象 + BufferedImage image = toBufferedImage(matrix); + // 尝试将 BufferedImage 对象写入文件 + if (!ImageIO.write(image, format, file)) { + // 如果写入失败,抛出 IOException 异常 + throw new IOException("Could not write an image of format " + + format + " to " + file); + } + } + + /** + * 将二维码矩阵转换为指定格式的图像并写入输出流。 + * + * @param matrix 要转换和写入的二维码矩阵 + * @param format 图像格式,如 "png", "jpg" 等 + * @param stream 要写入的输出流对象 + * @throws IOException 如果写入过程中出现 I/O 错误 + */ + public static void writeToStream(BitMatrix matrix, String format, + OutputStream stream) throws IOException { + // 将二维码矩阵转换为 BufferedImage 对象 + BufferedImage image = toBufferedImage(matrix); + // 尝试将 BufferedImage 对象写入输出流 + if (!ImageIO.write(image, format, stream)) { + // 如果写入失败,抛出 IOException 异常 + throw new IOException("Could not write an image of format " + + format); + } + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/web/filter/FilterEncoding.java b/src/wcp-web/src/main/java/com/farm/web/filter/FilterEncoding.java new file mode 100644 index 0000000..04ff399 --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/web/filter/FilterEncoding.java @@ -0,0 +1,68 @@ +// 声明包路径 +package com.farm.web.filter; + +// 导入用于处理输入输出异常的类 +import java.io.IOException; + +// 导入 Servlet 过滤器接口 +import javax.servlet.Filter; +// 导入过滤器链接口,用于将请求传递给下一个过滤器或目标资源 +import javax.servlet.FilterChain; +// 导入过滤器配置对象,用于获取过滤器的初始化参数 +import javax.servlet.FilterConfig; +// 导入用于处理 Servlet 异常的类 +import javax.servlet.ServletException; +// 导入 Servlet 请求接口 +import javax.servlet.ServletRequest; +// 导入 Servlet 响应接口 +import javax.servlet.ServletResponse; + +// 导入应用配置类,用于获取应用的配置信息 +import com.farm.core.config.AppConfig; + +/** + * 该过滤器用于设置请求的编码格式 + * + * @author WangDong + * @date Mar 14, 2010 + */ +public class FilterEncoding implements Filter { + + /** + * 过滤器销毁时调用的方法,通常用于释放资源 + * 目前方法体为空,可根据实际需求添加资源释放逻辑 + */ + public void destroy() { + // TODO Auto-generated method stub + } + + /** + * 过滤器的核心方法,用于对请求和响应进行处理 + * + * @param arg0 Servlet 请求对象 + * @param arg1 Servlet 响应对象 + * @param arg2 过滤器链对象 + * @throws IOException 如果在处理过程中发生输入输出异常 + * @throws ServletException 如果在处理过程中发生 Servlet 异常 + */ + public void doFilter(ServletRequest arg0, ServletResponse arg1, + FilterChain arg2) throws IOException, ServletException { + // 从应用配置中获取过滤器的编码配置 + String encode = AppConfig.getString("config.filter.encode"); + // 设置请求的字符编码为从配置中获取的编码 + arg0.setCharacterEncoding(encode); + // 将请求和响应传递给过滤器链中的下一个过滤器或目标资源 + arg2.doFilter(arg0, arg1); + } + + /** + * 过滤器初始化时调用的方法,用于获取过滤器的配置信息 + * 目前方法体为空,可根据实际需求添加初始化逻辑 + * + * @param arg0 过滤器配置对象 + * @throws ServletException 如果在初始化过程中发生 Servlet 异常 + */ + public void init(FilterConfig arg0) throws ServletException { + // TODO Auto-generated method stub + } +} diff --git a/src/wcp-web/src/main/java/com/farm/web/filter/FilterLogInfo.java b/src/wcp-web/src/main/java/com/farm/web/filter/FilterLogInfo.java new file mode 100644 index 0000000..3eb7f99 --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/web/filter/FilterLogInfo.java @@ -0,0 +1,99 @@ +// 声明该类所在的包 +package com.farm.web.filter; + +// 导入用于处理输入输出异常的类 +import java.io.IOException; + +// 导入 Servlet 过滤器接口 +import javax.servlet.Filter; +// 导入过滤器链接口,用于将请求传递给下一个过滤器或目标 Servlet +import javax.servlet.FilterChain; +// 导入过滤器配置对象,用于获取过滤器的初始化参数 +import javax.servlet.FilterConfig; +// 导入 Servlet 异常类,用于处理 Servlet 相关的异常 +import javax.servlet.ServletException; +// 导入 Servlet 请求接口,代表客户端的请求 +import javax.servlet.ServletRequest; +// 导入 Servlet 响应接口,代表服务器的响应 +import javax.servlet.ServletResponse; +// 导入 HTTP 请求接口,继承自 ServletRequest,用于处理 HTTP 协议的请求 +import javax.servlet.http.HttpServletRequest; +// 导入 HTTP 会话接口,用于管理用户的会话信息 +import javax.servlet.http.HttpSession; + +// 导入 Log4j 的 MDC 类,用于在日志中添加上下文信息 +import org.apache.log4j.MDC; + +// 导入登录用户实体类,用于表示已登录的用户 +import com.farm.core.auth.domain.LoginUser; +// 导入农场系统的常量类,包含一些常用的常量 +import com.farm.web.constant.FarmConstant; + +/** + * 该过滤器的作用是在日志中添加请求的 IP 地址和用户 ID 信息。 + * 它会在处理每个请求时,将客户端的 IP 地址和当前登录用户的 ID 存入 MDC 中,方便在日志中记录这些信息。 + */ +public class FilterLogInfo implements Filter { + + /** + * 过滤器销毁时调用的方法,通常用于释放过滤器所占用的资源。 + * 目前此方法为空,若有资源需要释放,可在此处添加相应代码。 + */ + @Override + public void destroy() { + // TODO Auto-generated method stub + } + + /** + * 过滤器的核心方法,用于处理请求和响应。 + * 它会在请求被传递给下一个过滤器或目标 Servlet 之前,将客户端的 IP 地址和当前登录用户的 ID 存入 MDC 中。 + * + * @param arg0 Servlet 请求对象,代表客户端的请求 + * @param arg1 Servlet 响应对象,代表服务器的响应 + * @param arg2 过滤器链对象,用于将请求传递给下一个过滤器或目标 Servlet + * @throws IOException 如果在输入输出操作中发生异常 + * @throws ServletException 如果在 Servlet 处理过程中发生异常 + */ + @Override + public void doFilter(ServletRequest arg0, ServletResponse arg1, + FilterChain arg2) throws IOException, ServletException { + // 将 ServletRequest 对象转换为 HttpServletRequest 对象,以便处理 HTTP 相关的请求 + HttpServletRequest req = (HttpServletRequest) arg0; + // 获取当前请求的会话对象 + HttpSession session = req.getSession(); + // 将客户端的 IP 地址存入 MDC 中,键为 "IP" + MDC.put("IP", arg0.getRemoteAddr()); + // 检查会话对象是否为空 + if (session == null) { + // 若会话为空,将用户 ID 标记为 "NONE" 存入 MDC 中 + MDC.put("USERID", "NONE"); + } else { + // 从会话中获取当前登录的用户对象 + LoginUser user = (LoginUser) session + .getAttribute(FarmConstant.SESSION_USEROBJ); + // 检查用户对象是否为空 + if (user == null) { + // 若用户对象为空,将用户 ID 标记为 "NONE" 存入 MDC 中 + MDC.put("USERID", "NONE"); + } else { + // 若用户对象不为空,将用户的 ID 存入 MDC 中 + MDC.put("USERID", user.getId()); + } + } + // 将请求和响应传递给过滤器链中的下一个过滤器或目标 Servlet + arg2.doFilter(arg0, arg1); + } + + /** + * 过滤器初始化时调用的方法,通常用于获取过滤器的初始化参数。 + * 目前此方法为空,若有初始化操作需要执行,可在此处添加相应代码。 + * + * @param arg0 过滤器配置对象,用于获取过滤器的初始化参数 + * @throws ServletException 如果在初始化过程中发生 Servlet 异常 + */ + @Override + public void init(FilterConfig arg0) throws ServletException { + // TODO Auto-generated method stub + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/web/filter/FilterValidate.java b/src/wcp-web/src/main/java/com/farm/web/filter/FilterValidate.java new file mode 100644 index 0000000..7e62045 --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/web/filter/FilterValidate.java @@ -0,0 +1,232 @@ +package com.farm.web.filter; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Set; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import org.apache.log4j.Logger; + +import com.farm.authority.FarmAuthorityService; +import com.farm.core.AuthorityService; +import com.farm.core.ParameterService; +import com.farm.core.auth.domain.AuthKey; +import com.farm.core.auth.domain.LoginUser; +import com.farm.core.auth.util.Urls; +import com.farm.parameter.FarmParameterService; +import com.farm.web.constant.FarmConstant; +import com.farm.web.online.OnlineUserOpImpl; +import com.farm.web.online.OnlineUserOpInter; + +/** + * 该过滤器用于对用户请求进行权限验证 + * @author WangDong + * @date Mar 14, 2010 + */ +public class FilterValidate implements Filter { + // 创建日志记录器,用于记录与该过滤器相关的日志信息 + private static final Logger log = Logger.getLogger(FilterValidate.class); + + /** + * 判断给定的URL是否为需要处理的URL + * @param urlStr 要判断的URL字符串 + * @return 如果是需要处理的URL返回true,否则返回false + */ + private boolean isURL(String urlStr) { + // 调用Urls工具类的方法判断URL是否以.do或.html结尾 + return Urls.isActionByUrl(urlStr, "do") || Urls.isActionByUrl(urlStr, "html"); + } + + /** + * 过滤器的核心方法,用于处理请求和响应 + * @param arg0 客户端请求对象 + * @param arg1 服务器响应对象 + * @param arg2 过滤器链对象 + * @throws IOException 输入输出异常 + * @throws ServletException Servlet异常 + */ + @SuppressWarnings("unchecked") + @Override + public void doFilter(ServletRequest arg0, ServletResponse arg1, FilterChain arg2) + throws IOException, ServletException { + // 获取参数服务实例,用于获取系统配置参数 + ParameterService parameterService = FarmParameterService.getInstance(); + // 获取请求的上下文路径 + String path = ((HttpServletRequest) arg0).getContextPath(); + // 构建基础路径 + String basePath = arg0.getScheme() + "://" + arg0.getServerName() + ":" + arg0.getServerPort() + path + "/"; + // 将ServletRequest转换为HttpServletRequest,以便处理HTTP请求 + HttpServletRequest request = (HttpServletRequest) arg0; + // 将ServletResponse转换为HttpServletResponse,以便处理HTTP响应 + HttpServletResponse response = (HttpServletResponse) arg1; + // 获取当前请求的会话对象 + HttpSession session = request.getSession(); + // 获取请求的完整URL + String requestUrl = request.getRequestURL().toString(); + // 如果端口为80端口则,将该端口去掉,认为是不需要端口的 + String formatUrl = Urls.formatUrl(requestUrl,requestUrl.indexOf(":")<8?basePath.replace(":80/","/"):basePath); + // 用于存储URL的操作键 + String key = null; + // 用于存储当前登录用户对象 + LoginUser currentUser = null; + // 用于存储权限验证的关键信息 + AuthKey authkey = null; + { + // 不是后台请求直接运行访问 + if (!isURL(formatUrl)) { + // 如果不是需要处理的URL,直接放行请求 + arg2.doFilter(arg0, arg1); + return; + } + } + { + // 组织URL参数 + // 从格式化后的URL中提取操作键 + key = Urls.getActionKey(formatUrl); + // 从会话中获取当前登录用户对象 + currentUser = (LoginUser) session.getAttribute(FarmConstant.SESSION_USEROBJ); + // 获取权限服务实例 + AuthorityService authority = FarmAuthorityService.getInstance(); + // 根据操作键获取权限验证信息 + authkey = authority.getAuthKey(key); + } + { + // 如果是不检查则任何连接都可执行 + if (parameterService.getParameter("config.auth.check.url").equals("false")) { + // 放开就是对权限不做验证 + // 如果配置为不进行权限检查,直接放行请求 + arg2.doFilter(arg0, arg1); + return; + } + } + { + // 权限是否被定义为禁用 + if (authkey != null && !authkey.isUseAble()) { + // 如果权限被禁用,返回405错误并给出提示信息 + response.sendError(405, "当前用户没有权限请求该资源!"); + return; + } + } + // 从操作键中提取子键 + String subKey = Urls.getActionSubKey(key); + { + // 是否可以直接访问 + // 未被定义和不需要登录的均可以直接访问 + if (authkey != null && !authkey.isLogin()) { + // 如果权限不需要登录,直接放行请求 + arg2.doFilter(arg0, arg1); + return; + } + // 判断是否需要用户权限认证 + // 获取不需要权限认证的URL前缀配置 + String prefix = parameterService.getParameter("config.url.free.path.prefix"); + if (prefix != null && subKey.indexOf("/" + prefix) == 0) { + // 如果子键以配置的前缀开头,直接放行请求 + arg2.doFilter(arg0, arg1); + return; + } + } + { + // 判断是否是登录即可访问 + // 获取登录后可访问的URL前缀配置 + String prefix = parameterService.getParameter("config.url.login.path.prefix"); + if (prefix != null && subKey.indexOf("/" + prefix) == 0) { + if (isURL(formatUrl)) { + if (currentUser == null) { + // 如果用户未登录,重定向到默认首页 + response.sendRedirect(parameterService.getParameter("config.index.defaultpage")); + return; + } else { + // 如果用户已登录,直接放行请求 + arg2.doFilter(arg0, arg1); + return; + } + } + } + if (authkey != null && authkey.isLogin() && !authkey.isCheck()) { + // 如果权限需要登录但不需要检查,直接放行请求 + arg2.doFilter(arg0, arg1); + return; + } + } + { + // 处理登录操作 + // 获取登录页面的URL配置,并分割成数组 + String[] urls = parameterService.getParameter("config.url.login.indexs").split(","); + if (isURL(formatUrl)) { + // 如果是用户执行登录操作登录就放过 + if (Arrays.asList(urls).contains(key)) { + // 如果当前请求的操作键是登录页面的URL,直接放行请求 + arg2.doFilter(arg0, arg1); + return; + } + } + } + { + // 处理未登录用户---让其登录 + if (isURL(formatUrl)) { + if (currentUser == null) { + if (request.getMethod().equals("GET")) { + // 如果是GET请求,将当前请求的URL和参数存储到会话中 + session.setAttribute(parameterService.getParameter("farm.constant.session.key.go.url"), + requestUrl + "?" + Urls.getUrlParameters(request)); + } + // 重定向到登录页面 + response.sendRedirect(basePath + parameterService.getParameter("config.url.login")); + return; + } + } + } + { + // 处理--online--用户在线 + // 创建在线用户操作实例 + OnlineUserOpInter ouop = OnlineUserOpImpl.getInstance(request.getRemoteAddr(), currentUser.getLoginname(), + session); + if (parameterService.getParameter("config.auth.multi.user").equals("false")) { + // 如果不允许多用户登录,处理用户访问 + ouop.userVisitHandle(); + } + } + // 验证登录用户权限(用户是否有该权限) + { + // 从会话中获取用户拥有的操作权限集合 + Set usraction = ((Set) request.getSession().getAttribute(FarmConstant.SESSION_USERACTION)); + if (authkey != null && !usraction.contains(key)) { + // 如果用户没有该操作权限,记录日志并返回405错误 + log.info("用户未经授权访问'" + authkey.getTitle() + "(" + key + ")'被拦截"); + response.sendError(405, "当前用户没有权限请求该资源!"); + return; + } + } + // 非受管的权限可以直接登录 + // 放行请求 + arg2.doFilter(arg0, arg1); + return; + } + + // ----------------------------------------------------------- + /** + * 过滤器初始化方法,在过滤器启动时调用 + * @param arg0 过滤器配置对象 + * @throws ServletException Servlet异常 + */ + @Override + public void init(FilterConfig arg0) throws ServletException { + } + + /** + * 过滤器销毁方法,在过滤器关闭时调用 + */ + @Override + public void destroy() { + } +} \ No newline at end of file diff --git a/src/wcp-web/src/main/java/com/farm/web/init/InitParameter.java b/src/wcp-web/src/main/java/com/farm/web/init/InitParameter.java new file mode 100644 index 0000000..d3c7ea5 --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/web/init/InitParameter.java @@ -0,0 +1,98 @@ +// 声明该类所在的包 +package com.farm.web.init; + +// 导入 DecimalFormat 类,用于格式化数字 +import java.text.DecimalFormat; + +// 导入 ServletContext 类,用于表示 Servlet 上下文 +import javax.servlet.ServletContext; + +// 导入常量变量服务实现类,用于注册常量 +import com.farm.parameter.service.impl.ConstantVarService; +// 导入属性文件服务实现类,用于注册配置文件 +import com.farm.parameter.service.impl.PropertiesFileService; +// 导入农场系统的常量类,包含一些常用的常量 +import com.farm.web.constant.FarmConstant; +// 导入 Servlet 初始化任务接口 +import com.farm.web.task.ServletInitJobInter; + +/** + * 该类实现了 ServletInitJobInter 接口,用于在 Servlet 初始化时执行一些初始化任务, + * 主要包括注册常量、注册配置文件以及记录 JVM 内存信息。 + */ +public class InitParameter implements ServletInitJobInter { + + /** + * 执行初始化任务的方法,在 Servlet 初始化时被调用。 + * + * @param context Servlet 上下文对象,用于获取 Servlet 相关的信息 + */ + @Override + public void execute(ServletContext context) { + // 注册常量,将 FarmConstant 中的常量注册到 ConstantVarService 中 + // 注册登录时间的会话键常量 + ConstantVarService.registConstant("farm.constant.session.key.logintime.", FarmConstant.SESSION_LOGINTIME); + // 注册当前组织的会话键常量 + ConstantVarService.registConstant("farm.constant.session.key.current.org", FarmConstant.SESSION_ORG); + // 注册当前角色的会话键常量 + ConstantVarService.registConstant("farm.constant.session.key.current.roles", FarmConstant.SESSION_ROLES); + // 注册当前用户操作的会话键常量 + ConstantVarService.registConstant("farm.constant.session.key.current.actions", FarmConstant.SESSION_USERACTION); + // 注册当前用户菜单的会话键常量 + ConstantVarService.registConstant("farm.constant.session.key.current.menu", FarmConstant.SESSION_USERMENU); + // 注册当前用户对象的会话键常量 + ConstantVarService.registConstant("farm.constant.session.key.current.user", FarmConstant.SESSION_USEROBJ); + // 注册当前用户照片的会话键常量 + ConstantVarService.registConstant("farm.constant.session.key.current.userphoto", + FarmConstant.SESSION_USERPHOTO); + // 注册跳转 URL 的会话键常量 + ConstantVarService.registConstant("farm.constant.session.key.go.url", FarmConstant.SESSION_GO_URL); + // 注册来源 URL 的会话键常量 + ConstantVarService.registConstant("farm.constant.session.key.from.url", FarmConstant.SESSION_FROM_URL); + // 注册菜单树编码单元长度的常量 + ConstantVarService.registConstant("farm.constant.app.treecodelen", + String.valueOf(FarmConstant.MENU_TREECODE_UNIT_LENGTH)); + // 注册 Web 应用根路径的常量 + ConstantVarService.registConstant("farm.constant.webroot.path", context.getRealPath("")); + + // 注册配置文件,将不同的配置文件注册到 PropertiesFileService 中 + PropertiesFileService.registConstant("config"); + PropertiesFileService.registConstant("llm"); + PropertiesFileService.registConstant("wda"); + PropertiesFileService.registConstant("indexConfig"); + PropertiesFileService.registConstant("jdbc"); + PropertiesFileService.registConstant("document"); + PropertiesFileService.registConstant("cache"); + PropertiesFileService.registConstant("webapp"); + PropertiesFileService.registConstant("i18n"); + PropertiesFileService.registConstant("about"); + PropertiesFileService.registConstant("rmi"); + PropertiesFileService.registConstant("email"); + + // 调用 memory 方法记录 JVM 内存信息 + memory(); + } + + /** + * 记录 JVM 内存信息的方法,将 JVM 的总内存、最大内存和空闲内存信息注册到 ConstantVarService 中。 + */ + private static void memory() { + // 创建一个 DecimalFormat 对象,用于格式化内存大小为两位小数 + DecimalFormat df = new DecimalFormat("0.00"); + // 获取 JVM 的总内存 + long totalMem = Runtime.getRuntime().totalMemory(); + // 将 JVM 总内存信息注册到 ConstantVarService 中,单位为 MB + ConstantVarService.registConstant("farm.jvm.memory.total", df.format(totalMem / 1024 / 1024) + " MB"); + + // 获取 JVM 的最大内存 + long maxMem = Runtime.getRuntime().maxMemory(); + // 将 JVM 最大内存信息注册到 ConstantVarService 中,单位为 MB + ConstantVarService.registConstant("farm.jvm.memory.max", df.format(maxMem / 1024 / 1024) + " MB"); + + // 获取 JVM 的空闲内存 + long freeMem = Runtime.getRuntime().freeMemory(); + // 将 JVM 空闲内存信息注册到 ConstantVarService 中,单位为 MB + ConstantVarService.registConstant("farm.jvm.memory.free", df.format(freeMem / 1024 / 1024) + " MB"); + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/web/init/InitRmiInter.java b/src/wcp-web/src/main/java/com/farm/web/init/InitRmiInter.java new file mode 100644 index 0000000..e921c8e --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/web/init/InitRmiInter.java @@ -0,0 +1,83 @@ +// 声明该类所在的包 +package com.farm.web.init; + +// 导入用于处理格式错误的 URL 异常的类 +import java.net.MalformedURLException; +// 导入用于处理通道已经绑定异常的类 +import java.nio.channels.AlreadyBoundException; +// 导入 RMI 命名服务相关的类,用于在 RMI 中绑定和查找远程对象 +import java.rmi.Naming; +// 导入用于处理远程方法调用异常的类 +import java.rmi.RemoteException; +// 导入用于定位 RMI 注册表的类 +import java.rmi.registry.LocateRegistry; + +// 导入 Servlet 上下文类,代表整个 Web 应用程序 +import javax.servlet.ServletContext; + +// 导入 Log4j 的日志记录器类,用于记录程序运行时的日志信息 +import org.apache.log4j.Logger; + +// 导入参数服务接口 +import com.farm.core.ParameterService; +// 导入农场参数服务类,用于获取系统配置参数 +import com.farm.parameter.FarmParameterService; +// 导入 WCP 应用的远程接口 +import com.farm.wcp.api.WcpAppInter; +// 导入 WDA 应用的远程接口 +import com.farm.wda.inter.WdaAppInter; +// 导入验证过滤器类 +import com.farm.web.filter.FilterValidate; +// 导入 WCP 应用的实现类 +import com.farm.web.rmi.impl.WcpAppImpl; +// 导入 Servlet 初始化任务接口 +import com.farm.web.task.ServletInitJobInter; + +/** + * 该类实现了 ServletInitJobInter 接口,用于在 Servlet 初始化时启动 RMI(远程方法调用)服务。 + */ +public class InitRmiInter implements ServletInitJobInter { + // 创建一个日志记录器,用于记录与该类相关的日志信息 + private static final Logger log = Logger.getLogger(InitRmiInter.class); + + /** + * 该方法在 Servlet 初始化时执行,用于启动 RMI 服务。 + * + * @param context Servlet 上下文对象,可用于获取 Web 应用的相关信息 + */ + @Override + public void execute(ServletContext context) { + try { + // 从农场参数服务中获取配置项 "config.local.rmi.state" 的值,并转换为大写 + // 若该值为 "FALSE",则不启动 RMI 服务,直接返回 + if (FarmParameterService.getInstance().getParameter("config.local.rmi.state").toUpperCase().equals("FALSE")) { + return; + } + // 从农场参数服务中获取配置项 "config.local.rmi.port" 的值,并转换为整数类型 + int port = Integer.valueOf(FarmParameterService.getInstance().getParameter("config.local.rmi.port")); + // 构建 RMI 服务的 URL,格式为 rmi://127.0.0.1:端口号/wcpapp + String rui = "rmi://127.0.0.1:" + port + "/wcpapp"; + // 创建 WcpAppInter 接口的实现类实例 + WcpAppInter wda = new WcpAppImpl(); + // 创建指定端口的 RMI 注册表 + LocateRegistry.createRegistry(port); + // 将 WcpAppInter 接口的实现类实例绑定到指定的 RMI URL 上 + Naming.rebind(rui, wda); + // 记录日志,表明 RMI 服务已成功启动,并输出服务的 URL + log.info("启动RMI服务" + rui); + } catch (RemoteException e) { + // 若在创建远程对象时发生异常,输出错误信息并打印异常堆栈信息 + System.out.println("创建远程对象发生异常!"); + e.printStackTrace(); + } catch (AlreadyBoundException e) { + // 若发生重复绑定对象的异常,输出错误信息并打印异常堆栈信息 + System.out.println("发生重复绑定对象异常!"); + e.printStackTrace(); + } catch (MalformedURLException e) { + // 若 RMI URL 格式错误,输出错误信息并打印异常堆栈信息 + System.out.println("发生URL畸形异常!"); + e.printStackTrace(); + } + } +} + diff --git a/src/wcp-web/src/main/java/com/farm/web/rmi/impl/WcpAppImpl.java b/src/wcp-web/src/main/java/com/farm/web/rmi/impl/WcpAppImpl.java new file mode 100644 index 0000000..41ce427 --- /dev/null +++ b/src/wcp-web/src/main/java/com/farm/web/rmi/impl/WcpAppImpl.java @@ -0,0 +1,184 @@ +package com.farm.web.rmi.impl; + +// 导入远程异常类,用于处理 RMI 调用过程中可能出现的异常 +import java.rmi.RemoteException; +// 导入单播远程对象类,是实现 RMI 服务的基础类 +import java.rmi.server.UnicastRemoteObject; +// 导入 HashMap 类,用于存储键值对数据 +import java.util.HashMap; +// 导入 List 接口,用于存储一组对象 +import java.util.List; +// 导入 Map 接口,用于存储键值对映射 +import java.util.Map; + +// 导入农场权限服务类 +import com.farm.authority.FarmAuthorityService; +// 导入登录用户实体类 +import com.farm.core.auth.domain.LoginUser; +// 导入数据结果类,用于封装查询结果 +import com.farm.core.sql.result.DataResult; +// 导入文档完整信息实体类 +import com.farm.doc.domain.ex.DocEntire; +// 导入文档类型简要信息实体类 +import com.farm.doc.domain.ex.TypeBrief; +// 导入农场文档管理接口 +import com.farm.doc.server.FarmDocManagerInter; +// 导入农场文档操作权限接口及权限类型枚举 +import com.farm.doc.server.FarmDocOperateRightInter.POP_TYPE; +// 导入农场文档类型管理接口 +import com.farm.doc.server.FarmDocTypeInter; +// 导入农场文件索引管理接口 +import com.farm.doc.server.FarmFileIndexManagerInter; +// 导入农场文件管理接口 +import com.farm.doc.server.FarmFileManagerInter; +// 导入 Spring 工具类,用于获取 Spring 容器中的 Bean +import com.farm.util.spring.BeanFactory; +// 导入 Wcp 应用接口 +import com.farm.wcp.api.WcpAppInter; +// 导入文档创建错误异常类 +import com.farm.wcp.api.exception.DocCreatErrorExcepiton; +// 导入结果实体类 +import com.farm.wcp.domain.Results; +// 导入知识服务接口 +import com.farm.wcp.know.service.KnowServiceInter; + +// 定义 WcpAppImpl 类,继承 UnicastRemoteObject 并实现 WcpAppInter 接口 +public class WcpAppImpl extends UnicastRemoteObject implements WcpAppInter { + // 构造函数,抛出 RemoteException 异常 + public WcpAppImpl() throws RemoteException { + // 调用父类的构造函数 + super(); + } + + // 定义农场文件索引管理实现类对象,通过 Spring 工厂获取 Bean + private FarmFileIndexManagerInter farmFileIndexManagerImpl = (FarmFileIndexManagerInter) BeanFactory + .getBean("farmFileIndexManagerImpl"); + + // 定义农场文件管理实现类对象,通过 Spring 工厂获取 Bean + private FarmFileManagerInter farmFileManagerImpl = (FarmFileManagerInter) BeanFactory + .getBean("farmFileManagerImpl"); + + // 定义农场文档管理实现类对象,通过 Spring 工厂获取 Bean + private FarmDocManagerInter farmDocManagerImpl = (FarmDocManagerInter) BeanFactory.getBean("farmDocManagerImpl"); + // 定义知识服务实现类对象,通过 Spring 工厂获取 Bean + private KnowServiceInter KnowServiceImpl = (KnowServiceInter) BeanFactory.getBean("knowServiceImpl"); + + // 定义农场文档类型管理实现类对象,通过 Spring 工厂获取 Bean + private FarmDocTypeInter farmDocTypeManagerImpl = (FarmDocTypeInter) BeanFactory.getBean("farmDocTypeManagerImpl"); + + // 定义序列化版本号 + private static final long serialVersionUID = 1L; + + /** + * 创建 HTML 知识文档 + * @param knowtitle 知识文档标题 + * @param knowtypeId 知识文档类型 ID + * @param text 知识文档内容 + * @param knowtag 知识文档标签 + * @param currentUserId 当前用户 ID + * @return 新创建文档的 ID + * @throws RemoteException 远程调用异常 + * @throws DocCreatErrorExcepiton 文档创建错误异常 + */ + @Override + public String creatHTMLKnow(String knowtitle, String knowtypeId, String text, String knowtag, String currentUserId) + throws RemoteException, DocCreatErrorExcepiton { + // 定义文档完整信息对象 + DocEntire doc = null; + try { + // 调用知识服务的创建知识方法 + doc = KnowServiceImpl.creatKnow(knowtitle, knowtypeId, text, knowtag, POP_TYPE.PUB, POP_TYPE.PUB, null, + null); + } catch (Exception e) { + // 捕获异常并抛出文档创建错误异常 + throw new DocCreatErrorExcepiton(e); + } + // 返回新创建文档的 ID + return doc.getDoc().getId(); + } + + /** + * 获取指定类型的文档列表 + * @param typeid 文档类型 ID + * @param pagesize 每页显示的文档数量 + * @param currentpage 当前页码 + * @param loginname 登录用户名 + * @return 包含文档列表的结果对象 + * @throws RemoteException 远程调用异常 + */ + @Override + public Results getTypeDocs(String typeid, int pagesize, int currentpage, String loginname) throws RemoteException { + // 根据登录名获取登录用户对象 + LoginUser user = FarmAuthorityService.getInstance().getUserByLoginName(loginname); + // 调用文档类型管理接口的方法获取指定类型的文档数据结果 + DataResult docs = farmDocTypeManagerImpl.getTypeDocs(user, typeid, pagesize, currentpage); + // 创建结果对象 + Results result = new Results(); + // 设置结果对象的文档列表 + result.setList(docs.getResultList()); + // 设置结果对象的当前页码 + result.setCurrentpage(currentpage); + // 设置结果对象的每页显示数量 + result.setPagesize(pagesize); + // 设置结果对象的总记录数 + result.setTotalsize(docs.getTotalSize()); + // 返回结果对象 + return result; + } + + /** + * 获取所有文档类型信息 + * @param loginname 登录用户名 + * @return 包含文档类型信息的结果对象 + * @throws RemoteException 远程调用异常 + */ + @Override + public Results getAllTypes(String loginname) throws RemoteException { + // 根据登录名获取登录用户对象 + LoginUser user = FarmAuthorityService.getInstance().getUserByLoginName(loginname); + // 调用文档类型管理接口的方法获取可供阅读文档的公开类型列表 + List types = farmDocTypeManagerImpl.getPopTypesForReadDoc(user); + // 创建结果对象 + Results result = new Results(); + // 遍历文档类型列表 + for (TypeBrief node : types) { + // 创建一个 HashMap 用于存储文档类型信息 + Map map = new HashMap<>(); + // 存储文档类型名称 + map.put("NAME", node.getName()); + // 存储文档类型 ID + map.put("ID", node.getId()); + // 存储文档类型父 ID + map.put("PARENTID", node.getParentid()); + // 存储文档类型相关数量 + map.put("NUM", node.getNum()); + // 存储文档类型 + map.put("TYPE", node.getType()); + // 将文档类型信息添加到结果对象的列表中 + result.getList().add(map); + } + // 设置结果对象的当前页码为 0 + result.setCurrentpage(0); + // 设置结果对象的每页显示数量为 1 + result.setPagesize(1); + // 设置结果对象的总记录数为文档类型列表的大小 + result.setTotalsize(types.size()); + // 返回结果对象 + return result; + } + + /** + * 运行 Lucene 索引 + * @param fileid 文件 ID + * @param docid 文档 ID + * @param text 文本内容 + * @throws RemoteException 远程调用异常 + */ + @SuppressWarnings("deprecation") + @Override + public void runLuceneIndex(String fileid, String docid, String text) throws RemoteException { + // 调用文件索引管理接口的方法添加文件的 Lucene 索引 + farmFileIndexManagerImpl.addFileLuceneIndex(fileid, farmDocManagerImpl.getDoc(docid), text); + } + +} diff --git a/src/wcp-web/src/main/webapp/test/javascript/highstock.1.1.js b/src/wcp-web/src/main/webapp/test/javascript/highstock.1.1.js new file mode 100644 index 0000000..e6952b6 --- /dev/null +++ b/src/wcp-web/src/main/webapp/test/javascript/highstock.1.1.js @@ -0,0 +1,22136 @@ +// 这是 Google Closure Compiler 的编译指令,指定编译级别为简单优化 +// ==ClosureCompiler== +// @compilation_level SIMPLE_OPTIMIZATIONS + +/** + * 版权声明:Highstock JS v2.0.3 版本(发布于 2014-07-03) + * + * 版权所有者为 Torstein Honsi(2009 - 2014) + * + * 许可协议请参考:www.highcharts.com/license + */ + +// JSLint 配置选项,用于代码检查 +/*global Highcharts, document, window, navigator, setInterval, clearInterval, clearTimeout, setTimeout, location, jQuery, $, console, each, grep */ +/*jslint ass: true, sloppy: true, forin: true, plusplus: true, nomen: true, vars: true, regexp: true, newcap: true, browser: true, continue: true, white: true */ +(function () { + // 封装的变量 + // 定义一个未定义的变量,用于后续表示未定义的值 + var UNDEFINED, + // 获取 document 对象,代表整个 HTML 文档 + doc = document, + // 获取 window 对象,代表浏览器窗口 + win = window, + // 获取 Math 对象,用于数学计算 + math = Math, + // 存储 Math.round 方法,用于四舍五入 + mathRound = math.round, + // 存储 Math.floor 方法,用于向下取整 + mathFloor = math.floor, + // 存储 Math.ceil 方法,用于向上取整 + mathCeil = math.ceil, + // 存储 Math.max 方法,用于获取最大值 + mathMax = math.max, + // 存储 Math.min 方法,用于获取最小值 + mathMin = math.min, + // 存储 Math.abs 方法,用于获取绝对值 + mathAbs = math.abs, + // 存储 Math.cos 方法,用于计算余弦值 + mathCos = math.cos, + // 存储 Math.sin 方法,用于计算正弦值 + mathSin = math.sin, + // 存储 Math.PI 常量,即圆周率 + mathPI = math.PI, + // 角度转弧度的系数 + deg2rad = mathPI * 2 / 360, + + // 一些变量 + // 获取浏览器的用户代理字符串 + userAgent = navigator.userAgent, + // 判断是否为 Opera 浏览器 + isOpera = win.opera, + // 判断是否为 Internet Explorer 浏览器 + isIE = /msie/i.test(userAgent) && !isOpera, + // 判断文档模式是否为 IE8 + docMode8 = doc.documentMode === 8, + // 判断是否为 WebKit 内核浏览器 + isWebKit = /AppleWebKit/.test(userAgent), + // 判断是否为 Firefox 浏览器 + isFirefox = /Firefox/.test(userAgent), + // 判断是否为触摸设备 + isTouchDevice = /(Mobile|Android|Windows Phone)/.test(userAgent), + // SVG 命名空间 + SVG_NS = 'http://www.w3.org/2000/svg', + // 判断浏览器是否支持 SVG + hasSVG = !!doc.createElementNS && !!doc.createElementNS(SVG_NS, 'svg').createSVGRect, + // 判断 Firefox 版本是否小于 4,存在双向文本显示问题 + hasBidiBug = isFirefox && parseInt(userAgent.split('Firefox/')[1], 10) < 4, // issue #38 + // 判断是否使用 CanVG 库(在不支持 SVG 且不是 IE 浏览器但支持 canvas 的情况下使用) + useCanVG = !hasSVG && !isIE && !!doc.createElement('canvas').getContext, + // 渲染器,后续会赋值 + Renderer, + // 判断是否支持触摸事件 + hasTouch, + // 存储符号大小的对象 + symbolSizes = {}, + // 用于生成唯一 ID 的计数器 + idCounter = 0, + // 垃圾回收容器,后续会赋值 + garbageBin, + // 默认选项,后续会赋值 + defaultOptions, + // 日期格式化函数,后续会赋值 + dateFormat, + // 全局动画设置,后续会赋值 + globalAnimation, + // 路径动画,后续会赋值 + pathAnim, + // 时间单位,后续会赋值 + timeUnits, + // 错误处理函数,后续会赋值 + error, + // 空函数,不执行任何操作 + noop = function () { return UNDEFINED; }, + // 存储所有图表对象的数组 + charts = [], + // 图表数量 + chartCount = 0, + // 产品名称 + PRODUCT = 'Highstock', + // 产品版本号 + VERSION = '2.0.3', + + // 一些常用字符串常量 + // HTML 中的 div 标签 + DIV = 'div', + // CSS 中的绝对定位 + ABSOLUTE = 'absolute', + // CSS 中的相对定位 + RELATIVE = 'relative', + // CSS 中的隐藏属性 + HIDDEN = 'hidden', + // 元素 ID 的前缀 + PREFIX = 'highcharts-', + // CSS 中的可见属性 + VISIBLE = 'visible', + // CSS 中的像素单位 + PX = 'px', + // CSS 中的无属性 + NONE = 'none', + // SVG 路径中的移动命令 + M = 'M', + // SVG 路径中的线段命令 + L = 'L', + // 匹配数字的正则表达式 + numRegex = /^[0-9]+$/, + // 正常状态 + NORMAL_STATE = '', + // 悬停状态 + HOVER_STATE = 'hover', + // 选择状态 + SELECT_STATE = 'select', + + // 用于扩展 Axis 的对象,后续会赋值 + AxisPlotLineOrBandExtension, + + // 属性常量 + // SVG 中的描边宽度属性 + STROKE_WIDTH = 'stroke-width', + + // 时间处理方法,会根据是否使用 UTC 进行改变 + makeTime, + timezoneOffset, + getMinutes, + getHours, + getDay, + getDate, + getMonth, + getFullYear, + setMinutes, + setHours, + setDate, + setMonth, + setFullYear, + + // 存储系列类型和对应类的映射 + seriesTypes = {}, + // Highcharts 命名空间,后续会赋值 + Highcharts; + + // 初始化 Highcharts 命名空间 + // 如果 window 对象上已经存在 Highcharts 对象,则抛出错误 + if (win.Highcharts) { + error(16, true); + } else { + // 否则,创建一个新的 Highcharts 对象并赋值给 window.Highcharts + Highcharts = win.Highcharts = {}; + } + + /** + * 扩展一个对象,将另一个对象的属性添加到第一个对象上 + * @param {Object} a 要被扩展的对象 + * @param {Object} b 要添加到第一个对象上的对象 + */ + function extend(a, b) { + // 用于遍历对象属性的变量 + var n; + // 如果 a 对象为空,则创建一个新的空对象 + if (!a) { + a = {}; + } + // 遍历 b 对象的所有属性 + for (n in b) { + // 将 b 对象的属性添加到 a 对象上 + a[n] = b[n]; + } + // 返回扩展后的 a 对象 + return a; + } + + /** + * 深度合并两个或多个对象,并返回一个新的对象。如果第一个参数为 true,则将第二个对象的内容复制到第一个对象中。 + * 之前这个函数重定向到 jQuery.extend(true),但有两个限制。第一,它会深度合并数组,这在 Highcharts 中需要变通处理。第二,它会从扩展的原型中复制属性。 + */ + function merge() { + // 循环变量 + var i, + // 获取所有参数 + args = arguments, + // 参数的长度 + len, + // 合并后的结果对象 + ret = {}, + // 递归复制函数 + doCopy = function (copy, original) { + // 存储属性值的变量 + var value, + // 存储属性名的变量 + key; + + // 如果 copy 不是对象,则创建一个新的空对象 + if (typeof copy !== 'object') { + copy = {}; + } + + // 遍历 original 对象的所有属性 + for (key in original) { + // 只处理对象自身的属性 + if (original.hasOwnProperty(key)) { + // 获取属性值 + value = original[key]; + + // 如果属性值是对象(非数组、非 DOM 节点),则递归复制 + if (value && typeof value === 'object' && Object.prototype.toString.call(value) !== '[object Array]' + && key !== 'renderTo' && typeof value.nodeType !== 'number') { + copy[key] = doCopy(copy[key] || {}, value); + // 对于基本类型和数组,直接复制 + } else { + copy[key] = original[key]; + } + } + } + // 返回复制后的对象 + return copy; + }; + + // 如果第一个参数为 true,则将第二个对象作为结果对象,并从第三个参数开始处理 + if (args[0] === true) { + ret = args[1]; + args = Array.prototype.slice.call(args, 2); + } + + // 获取参数的长度 + len = args.length; + // 遍历所有参数,进行合并 + for (i = 0; i < len; i++) { + ret = doCopy(ret, args[i]); + } + + // 返回合并后的对象 + return ret; + } + + /** + * parseInt 的快捷方式 + * @param {Object} s 要转换为整数的对象 + * @param {Number} mag 进制数,默认为 10 + */ + function pInt(s, mag) { + // 将 s 转换为整数,使用指定的进制数(默认为 10) + return parseInt(s, mag || 10); + } + + /** + * 检查一个对象是否为字符串 + * @param {Object} s 要检查的对象 + */ + function isString(s) { + // 判断 s 的类型是否为字符串 + return typeof s === 'string'; + } + + /** + * 检查一个对象是否为对象 + * @param {Object} obj 要检查的对象 + */ + function isObject(obj) { + // 判断 obj 是否存在且类型为对象 + return obj && typeof obj === 'object'; + }/** + * 检查一个对象是否为数组 + * @param {Object} obj 要检查的对象 + */ + function isArray(obj) { + // 使用 Object.prototype.toString.call 方法来判断对象是否为数组 + return Object.prototype.toString.call(obj) === '[object Array]'; + } + + /** + * 检查一个对象是否为数字 + * @param {Object} n 要检查的对象 + */ + function isNumber(n) { + // 通过判断对象的类型是否为 'number' 来确定是否为数字 + return typeof n === 'number'; + } + + /** + * 将对数刻度转换为线性刻度 + * @param {Number} num 要转换的对数刻度值 + */ + function log2lin(num) { + // 使用 Math.log 计算自然对数,再除以 Math.LN10 得到以 10 为底的对数,从而完成对数到线性的转换 + return math.log(num) / math.LN10; + } + + /** + * 将线性刻度转换为对数刻度 + * @param {Number} num 要转换的线性刻度值 + */ + function lin2log(num) { + // 使用 Math.pow 方法,以 10 为底,num 为指数进行计算,实现线性到对数的转换 + return math.pow(10, num); + } + + /** + * 从数组中移除指定元素的最后一次出现 + * @param {Array} arr 要操作的数组 + * @param {Mixed} item 要移除的元素 + */ + function erase(arr, item) { + // 从数组的最后一个元素开始向前遍历 + var i = arr.length; + while (i--) { + // 如果找到指定元素 + if (arr[i] === item) { + // 使用 splice 方法移除该元素 + arr.splice(i, 1); + // 找到并移除后,跳出循环 + break; + } + } + // 原代码注释了返回语句,这里不返回修改后的数组 + //return arr; + } + + /** + * 检查对象是否已定义且不为 null + * @param {Object} obj 要检查的对象 + */ + function defined(obj) { + // 通过比较对象是否不等于 UNDEFINED 且不等于 null 来判断对象是否已定义且不为空 + return obj !== UNDEFINED && obj !== null; + } + + /** + * 设置或获取元素的属性。不能使用 jQuery 的 attr 方法,因为它会尝试在 SVG 元素上设置扩展属性,这是不允许的 + * @param {Object} elem 要设置或获取属性的 DOM 元素 + * @param {String|Object} prop 属性名或包含键值对的对象 + * @param {String} value 如果只设置单个属性,该参数为属性值 + */ + function attr(elem, prop, value) { + var key, + ret; + + // 如果 prop 是字符串 + if (isString(prop)) { + // 如果提供了 value 参数,则设置属性值 + if (defined(value)) { + elem.setAttribute(prop, value); + // 否则,如果元素存在且有 getAttribute 方法,则获取属性值 + } else if (elem && elem.getAttribute) { // elem 可能在打印饼图演示时未定义 + ret = elem.getAttribute(prop); + } + // 如果 prop 是已定义的对象,则它是一个包含键值对的哈希对象 + } else if (defined(prop) && isObject(prop)) { + // 遍历对象的所有属性,为元素设置这些属性 + for (key in prop) { + elem.setAttribute(key, prop[key]); + } + } + // 返回获取到的属性值,如果没有获取操作则返回 undefined + return ret; + } + + /** + * 检查元素是否为数组,如果不是则将其转换为数组,类似于 MooTools 的 $.splat 方法 + * @param {Object} obj 要检查或转换的对象 + */ + function splat(obj) { + // 如果对象是数组则直接返回,否则将其包装在一个数组中返回 + return isArray(obj) ? obj : [obj]; + } + + /** + * 返回第一个已定义且不为 null 的值,类似于 MooTools 的 $.pick 方法 + */ + function pick() { + var args = arguments, + i, + arg, + length = args.length; + // 遍历所有参数 + for (i = 0; i < length; i++) { + arg = args[i]; + // 如果参数已定义且不为 null,则返回该参数 + if (arg !== UNDEFINED && arg !== null) { + return arg; + } + } + } + + /** + * 为指定元素设置 CSS 样式 + * @param {Object} el 要设置样式的元素 + * @param {Object} styles 包含驼峰命名法属性名的样式对象 + */ + function css(el, styles) { + // 如果是 IE 浏览器且不支持 SVG + if (isIE && !hasSVG) { // #2686 + // 如果样式对象中包含 opacity 属性 + if (styles && styles.opacity !== UNDEFINED) { + // 为 IE 浏览器添加 filter 属性来实现透明度效果 + styles.filter = 'alpha(opacity=' + (styles.opacity * 100) + ')'; + } + } + // 将样式对象的属性扩展到元素的 style 属性上 + extend(el.style, styles); + } + + /** + * 实用函数,用于创建带有属性和样式的元素 + * @param {Object} tag 元素标签名 + * @param {Object} attribs 元素的属性对象 + * @param {Object} styles 元素的样式对象 + * @param {Object} parent 元素的父元素 + * @param {Object} nopad 是否去除内边距、边框和外边距 + */ + function createElement(tag, attribs, styles, parent, nopad) { + // 创建指定标签名的元素 + var el = doc.createElement(tag); + // 如果提供了属性对象,则将其属性扩展到元素上 + if (attribs) { + extend(el, attribs); + } + // 如果 nopad 为真,则去除元素的内边距、边框和外边距 + if (nopad) { + css(el, {padding: 0, border: NONE, margin: 0}); + } + // 如果提供了样式对象,则为元素设置样式 + if (styles) { + css(el, styles); + } + // 如果提供了父元素,则将新元素添加到父元素中 + if (parent) { + parent.appendChild(el); + } + // 返回创建好的元素 + return el; + } + + /** + * 通过新成员扩展一个原型类 + * @param {Object} parent 父类 + * @param {Object} members 要添加到父类原型的成员对象 + */ + function extendClass(parent, members) { + // 创建一个空函数 + var object = function () { return UNDEFINED; }; + // 将空函数的原型设置为父类的实例 + object.prototype = new parent(); + // 将新成员扩展到空函数的原型上 + extend(object.prototype, members); + // 返回扩展后的类 + return object; + } + + /** + * 格式化一个数字并根据输入设置返回字符串 + * @param {Number} number 要格式化的输入数字 + * @param {Number} decimals 小数位数 + * @param {String} decPoint 小数点符号,默认为语言选项中指定的符号 + * @param {String} thousandsSep 千分位分隔符,默认为语言选项中指定的符号 + */ + function numberFormat(number, decimals, decPoint, thousandsSep) { + var lang = defaultOptions.lang, + // 处理输入数字,如果不是数字则转换为 0 + n = +number || 0, + // 确定小数位数,如果 decimals 为 -1 则保留原数字的小数位数,否则使用指定的小数位数(默认 2 位) + c = decimals === -1 ? + (n.toString().split('.')[1] || '').length : // preserve decimals + (isNaN(decimals = mathAbs(decimals)) ? 2 : decimals), + // 确定小数点符号,默认使用语言选项中的符号 + d = decPoint === undefined ? lang.decimalPoint : decPoint, + // 确定千分位分隔符,默认使用语言选项中的符号 + t = thousandsSep === undefined ? lang.thousandsSep : thousandsSep, + // 判断数字是否为负数,添加负号 + s = n < 0 ? "-" : "", + // 将数字转换为整数部分的字符串 + i = String(pInt(n = mathAbs(n).toFixed(c))), + // 计算整数部分长度对 3 取模的结果,用于添加千分位分隔符 + j = i.length > 3 ? i.length % 3 : 0; + + // 拼接格式化后的字符串,包括负号、千分位分隔符、小数点和小数部分 + return s + (j ? i.substr(0, j) + t : "") + i.substr(j).replace(/(\d{3})(?=\d)/g, "$1" + t) + + (c ? d + mathAbs(n - i).toFixed(c).slice(2) : ""); + } + + /** + * 在字符串前面填充 0 以达到指定长度 + * @param {Number} number 要填充的数字 + * @param {Number} length 填充后的目标长度 + */ + function pad(number, length) { + // 创建一个长度为 (目标长度 - 数字字符串长度 + 1) 的数组,用 0 填充并拼接在数字前面 + return new Array((length || 2) + 1 - String(number).length).join(0) + number; + } + + /** + * 用扩展功能包装一个方法,同时保留原函数 + * @param {Object} obj 方法所属的上下文对象 + * @param {String} method 要扩展的方法名 + * @param {Function} func 包装函数回调,该函数会接收与原函数相同的参数,并且原函数会作为第一个参数传入 + */ + function wrap(obj, method, func) { + // 保存原方法 + var proceed = obj[method]; + // 重写原方法 + obj[method] = function () { + // 将参数转换为数组 + var args = Array.prototype.slice.call(arguments); + // 将原方法插入到参数数组的开头 + args.unshift(proceed); + // 调用包装函数并返回结果 + return func.apply(this, args); + }; + } + + /** + * 基于 http://www.php.net/manual/en/function.strftime.php 实现日期格式化 + * @param {String} format 日期格式化字符串 + * @param {Number} timestamp 时间戳 + * @param {Boolean} capitalize 是否将结果字符串的首字母大写 + */ + dateFormat = function (format, timestamp, capitalize) { + // 如果时间戳未定义或不是有效的数字,则返回 'Invalid date' + if (!defined(timestamp) || isNaN(timestamp)) { + return 'Invalid date'; + } + // 如果未提供格式化字符串,则使用默认格式 + format = pick(format, '%Y-%m-%d %H:%M:%S'); + + // 创建一个 Date 对象,减去时区偏移量 + var date = new Date(timestamp - timezoneOffset), + key, // 用于下面的 for 循环 + // 获取基本的时间值 + hours = date[getHours](), + day = date[getDay](), + dayOfMonth = date[getDate](), + month = date[getMonth](), + fullYear = date[getFullYear](), + lang = defaultOptions.lang, + langWeekdays = lang.weekdays, + + // 列出所有格式化键。可以从外部添加自定义格式 + replacements = extend({ + + // 日期部分 + 'a': langWeekdays[day].substr(0, 3), // 短星期几,如 'Mon' + 'A': langWeekdays[day], // 长星期几,如 'Monday' + 'd': pad(dayOfMonth), // 两位数的月份中的日期,从 01 到 31 + 'e': dayOfMonth, // 月份中的日期,从 1 到 31 + + // 周部分(未实现) + //'W': weekNumber(), + + // 月份部分 + 'b': lang.shortMonths[month], // 短月份,如 'Jan' + 'B': lang.months[month], // 长月份,如 'January' + 'm': pad(month + 1), // 两位数的月份编号,从 01 到 12 + + // 年份部分 + 'y': fullYear.toString().substr(2, 2), // 两位数的年份,如 09 表示 2009 + 'Y': fullYear, // 四位数的年份,如 2009 + + // 时间部分 + 'H': pad(hours), // 24 小时制的两位数小时,从 00 到 23 + 'I': pad((hours % 12) || 12), // 12 小时制的两位数小时,从 00 到 11 + 'l': (hours % 12) || 12, // 12 小时制的小时,从 1 到 12 + 'M': pad(date[getMinutes]()), // 两位数的分钟,从 00 到 59 + 'p': hours < 12 ? 'AM' : 'PM', // 大写的 AM 或 PM + 'P': hours < 12 ? 'am' : 'pm', // 小写的 AM 或 PM + 'S': pad(date.getSeconds()), // 两位数的秒数,从 00 到 59 + 'L': pad(mathRound(timestamp % 1000), 3) // 毫秒(命名来自 Ruby) + }, Highcharts.dateFormats); + + // 进行替换操作 + for (key in replacements) { + // 循环替换所有匹配的格式化键 + while (format.indexOf('%' + key) !== -1) { // 使用循环替换比正则表达式更快 + format = format.replace('%' + key, typeof replacements[key] === 'function' ? replacements[key](timestamp) : replacements[key]); + } + } + + // 根据 capitalize 参数决定是否将结果字符串的首字母大写并返回 + return capitalize ? format.substr(0, 1).toUpperCase() + format.substr(1) : format; + }; + + /** + * 格式化单个变量。类似于 sprintf,但没有 % 前缀 + */ + function formatSingle(format, val) { + var floatRegex = /f$/, + decRegex = /\.([0-9])/, + lang = defaultOptions.lang, + decimals; + + // 如果格式化字符串以 'f' 结尾,表示是浮点数格式化 + if (floatRegex.test(format)) { + // 提取小数位数 + decimals = format.match(decRegex); + decimals = decimals ? decimals[1] : -1; + // 如果值不为 null,则进行数字格式化 + if (val !== null) { + val = numberFormat( + val, + decimals, + lang.decimalPoint, + format.indexOf(',') > -1 ? lang.thousandsSep : '' + ); + } + } else { + // 否则进行日期格式化 + val = dateFormat(format, val); + } + // 返回格式化后的值 + return val; + } + + + /** + * 根据Python的String.format方法的一部分规则格式化一个字符串。 + */ + function format(str, ctx) { + // 用于分割字符串的分隔符,这里是 '{' + var splitter = '{', + // 用于标记是否处于花括号内部,初始为false + isInside = false, + segment, + valueAndFormat, + path, + i, + len, + // 用于存储格式化后的各个部分 + ret = [], + val, + index; + + // 当字符串中还能找到分隔符时 + while ((index = str.indexOf(splitter))!== -1) { + // 提取分隔符之前的字符串片段 + segment = str.slice(0, index); + // 如果处于花括号内部(意味着正在处理闭合花括号的情况) + if (isInside) { + // 按 ':' 分割字符串片段,得到值和格式部分 + valueAndFormat = segment.split(':'); + // 提取路径部分(移除格式部分) + path = valueAndFormat.shift().split('.'); + len = path.length; + val = ctx; + + // 遍历路径,获取最终的值 + for (i = 0; i < len; i++) { + val = val[path[i]]; + } + + // 如果存在格式部分,对值进行格式化 + if (valueAndFormat.length) { + val = formatSingle(valueAndFormat.join(':'), val); + } + + // 将格式化后的值添加到结果数组中 + ret.push(val); + + } else { + // 如果不在花括号内部,直接将片段添加到结果数组中 + ret.push(segment); + + } + // 移除已经处理的部分,获取剩余的字符串 + str = str.slice(index + 1); + // 切换是否处于花括号内部的状态 + isInside =!isInside; + // 根据当前状态,设置下一个要查找的分隔符('{' 或 '}') + splitter = isInside? '}' : '{'; + } + // 将剩余的字符串添加到结果数组中 + ret.push(str); + // 将结果数组拼接成字符串并返回 + return ret.join(''); + } + + /** + * 获取一个数字的数量级 + */ + function getMagnitude(num) { + // 通过对数计算得到数量级 + return math.pow(10, mathFloor(math.log(num) / math.LN10)); + } + + /** + * 对一个区间进行归一化处理,使其为1、2、2.5和5的倍数 + * @param {Number} interval 要处理的区间值 + * @param {Array} multiples 倍数数组(可选) + * @param {Number} magnitude 数量级(可选) + * @param {Object} options 配置选项(可选) + */ + function normalizeTickInterval(interval, multiples, magnitude, options) { + var normalized, i; + + // 对区间进行归一化,除以数量级 + magnitude = pick(magnitude, 1); + normalized = interval / magnitude; + + // 如果没有传入倍数数组,设置默认的倍数数组 + if (!multiples) { + multiples = [1, 2, 2.5, 5, 10]; + + // 如果配置中不允许小数 + if (options && options.allowDecimals === false) { + if (magnitude === 1) { + multiples = [1, 2, 5, 10]; + } else if (magnitude <= 0.1) { + multiples = [1 / magnitude]; + } + } + } + + // 对区间进行归一化,使其为最接近的倍数 + for (i = 0; i < multiples.length; i++) { + interval = multiples[i]; + if (normalized <= (multiples[i] + (multiples[i + 1] || multiples[i])) / 2) { + break; + } + } + + // 将归一化后的区间乘以数量级,恢复到原来的数量级 + interval *= magnitude; + + return interval; + } + + /** + * 对对象数组进行稳定排序的实用方法,保持相等元素的顺序。 + * ECMA脚本标准没有指定元素相等时的行为。 + */ + function stableSort(arr, sortFunction) { + var length = arr.length, + sortValue, + i; + + // 为每个元素添加一个索引属性,用于稳定排序 + for (i = 0; i < length; i++) { + arr[i].ss_i = i; + } + + // 使用自定义的比较函数进行排序 + arr.sort(function (a, b) { + sortValue = sortFunction(a, b); + // 如果比较结果为0,根据索引进行排序 + return sortValue === 0? a.ss_i - b.ss_i : sortValue; + }); + + // 移除排序后元素的索引属性 + for (i = 0; i < length; i++) { + delete arr[i].ss_i; + } + } + + /** + * 非递归方法,用于找到数组中的最小值。Math.min在Chrome中处理超过150000个点时会引发最大调用栈大小超出错误。 + * 这个方法稍微慢一些,但更安全。 + */ + function arrayMin(data) { + var i = data.length, + min = data[0]; + + // 遍历数组,找到最小值 + while (i--) { + if (data[i] < min) { + min = data[i]; + } + } + return min; + } + + /** + * 非递归方法,用于找到数组中的最大值。Math.max在Chrome中处理超过150000个点时会引发最大调用栈大小超出错误。 + * 这个方法稍微慢一些,但更安全。 + */ + function arrayMax(data) { + var i = data.length, + max = data[0]; + + // 遍历数组,找到最大值 + while (i--) { + if (data[i] > max) { + max = data[i]; + } + } + return max; + } + + /** + * 实用方法,销毁给定对象上作为属性的任何SVGElement或VMLElement。 + * 它遍历所有属性,如果属性有destroy方法,则调用该方法,然后删除该属性。 + * @param {Object} obj 要销毁属性的对象 + * @param {Object} except 不销毁的属性,仅删除它 + */ + function destroyObjectProperties(obj, except) { + var n; + // 遍历对象的所有属性 + for (n in obj) { + // 如果属性非空,不是例外属性,并且有destroy方法 + if (obj[n] && obj[n]!== except && obj[n].destroy) { + // 调用属性的destroy方法 + obj[n].destroy(); + } + + // 删除该属性 + delete obj[n]; + } + } + + /** + * 通过将元素移动到垃圾桶并删除来丢弃元素 + * @param {Object} element 要丢弃的HTML节点 + */ + function discardElement(element) { + // 创建一个不属于DOM的垃圾桶元素 + if (!garbageBin) { + garbageBin = createElement(DIV); + } + + // 如果有要处理的元素,将其移动到垃圾桶并清空垃圾桶 + if (element) { + garbageBin.appendChild(element); + } + garbageBin.innerHTML = ''; + } + + /** + * 提供用于调试的错误消息,带有指向在线解释的链接 + */ + error = function (code, stop) { + var msg = 'Highcharts error #' + code + ': www.highcharts.com/errors/' + code; + if (stop) { + // 如果stop为true,抛出错误消息 + throw msg; + } + // 如果stop为false,并且浏览器控制台存在,在控制台打印错误消息 + if (win.console) { + console.log(msg); + } + }; + + /** + * 修复JS中浮点数的舍入误差 + * @param {Number} num 要修复的浮点数 + */ + function correctFloat(num) { + // 通过设置精度来修复浮点数误差 + return parseFloat( + num.toPrecision(14) + ); + } + + /** + * 设置全局动画为给定值,或者回退到给定图表的动画选项 + * @param {Object} animation 要设置的动画对象 + * @param {Object} chart 图表对象 + */ + function setAnimation(animation, chart) { + globalAnimation = pick(animation, chart.animation); + } + + /** + * 时间单位查找表 + */ + timeUnits = { + millisecond: 1, + second: 1000, + minute: 60000, + hour: 3600000, + day: 24 * 3600000, + week: 7 * 24 * 3600000, + month: 31 * 24 * 3600000, + year: 31556952000 + }; + + /** + * 跨适配器使用的路径插值算法 + */ + pathAnim = { + /** + * 准备开始和结束值,以便可以一对一地动画路径 + */ + init: function (elem, fromD, toD) { + fromD = fromD || ''; + var shift = elem.shift, + // 判断路径是否包含贝塞尔曲线指令 'C' + bezier = fromD.indexOf('C') > -1, + numParams = bezier? 7 : 3, + endLength, + slice, + i, + // 将开始路径字符串按空格分割成数组 + start = fromD.split(' '), + // 复制结束路径数组 + end = [].concat(toD), + startBaseLine, + endBaseLine, + // 使移动点具有像贝塞尔曲线一样的六个参数 + sixify = function (arr) { + i = arr.length; + while (i--) { + if (arr[i] === M) { + arr.splice(i + 1, 0, arr[i + 1], arr[i + 2], arr[i + 1], arr[i + 2]); + } + } + }; + + if (bezier) { + sixify(start); + sixify(end); + } + + // 如果是面积图,提取基线 + if (elem.isArea) { + startBaseLine = start.splice(start.length - 6, 6); + endBaseLine = end.splice(end.length - 6, 6); + } + + // 如果要移动点,在结束路径前添加虚拟点 + if (shift <= end.length / numParams && start.length === end.length) { + while (shift--) { + end = [].concat(end).splice(0, numParams).concat(end); + } + } + elem.shift = 0; + + // 如果开始路径长度小于结束路径长度,复制并追加最后一个点 + if (start.length) { + endLength = end.length; + while (start.length < endLength) { + + //bezier && sixify(start); + slice = [].concat(start).splice(start.length - numParams, numParams); + if (bezier) { + slice[numParams - 6] = slice[numParams - 2]; + slice[numParams - 5] = slice[numParams - 1]; + } + start = start.concat(slice); + } + } + + if (startBaseLine) { + start = start.concat(startBaseLine); + end = end.concat(endBaseLine); + } + return [start, end]; + }, + + /** + * 对路径的每个值进行插值并返回数组 + */ + step: function (start, end, pos, complete) { + var ret = [], + i = start.length, + startVal; + + // 如果动画完成,直接返回结束路径 + if (pos === 1) { + ret = complete; + + } else if (i === end.length && pos < 1) { + while (i--) { + startVal = parseFloat(start[i]); + // 如果是字母指令(如 M 或 L),直接使用原指令,否则进行插值计算 + ret[i] = + isNaN(startVal)? + start[i] : + pos * (parseFloat(end[i] - startVal)) + startVal; + + } + } else { + ret = end; + } + return ret; + } + }; + + (function ($) { + /** + * jQuery的默认HighchartsAdapter + */ + win.HighchartsAdapter = win.HighchartsAdapter || ($ && { + + /** + * 通过对jQuery应用一些扩展来初始化适配器 + */ + init: function (pathAnim) { + + // 扩展animate函数以支持SVG动画 + var Fx = $.fx, + Step = Fx.step, + dSetter, + Tween = $.Tween, + propHooks = Tween && Tween.propHooks, + opacityHook = $.cssHooks.opacity; + + /*jslint unparam: true*//* 允许此函数中未使用的参数x */ + $.extend($.easing, { + easeOutQuad: function (x, t, b, c, d) { + return -c * (t /= d) * (t - 2) + b; + } + }); + /*jslint unparam: false*/ + + // 扩展一些方法以检查elem.attr,这意味着它是一个Highcharts SVG对象 + $.each(['cur', '_default', 'width', 'height', 'opacity'], function (i, fn) { + var obj = Step, + base; + + // 处理不同的父对象 + if (fn === 'cur') { + obj = Fx.prototype; + } else if (fn === '_default' && Tween) { + obj = propHooks[fn]; + fn ='set'; + } + + // 覆盖方法 + base = obj[fn]; + if (base) { + + // 创建扩展后的函数替换 + obj[fn] = function (fx) { + + var elem; + + // Fx.prototype.cur不使用fx参数 + fx = i? fx : this; + + // 不在文本属性(如align)上运行动画 + if (fx.prop === 'align') { + return; + } + + // 快捷方式 + elem = fx.elem; + + // Fx.prototype.cur返回当前值。其他的是设置器,返回值没有效果。 + return elem.attr? + elem.attr(fx.prop, fn === 'cur'? UNDEFINED : fx.now) : + base.apply(this, arguments); + }; + } + }); + + // 扩展opacity获取器,在IE9和jQuery 1.10+中淡化透明度时需要 + wrap(opacityHook, 'get', function (proceed, elem, computed) { + return elem.attr? (elem.opacity || 0) : proceed.call(this, elem, computed); + }); + + + // 定义路径定义'd'的设置器函数 + dSetter = function (fx) { + var elem = fx.elem, + ends; + + // 通常在状态为0时设置开始和结束值,但有时可能不会发生,这里进行处理 + if (!fx.started) { + ends = pathAnim.init(elem, elem.d, elem.toD); + fx.start = ends[0]; + fx.end = ends[1]; + fx.started = true; + } + + + // 对路径的每个值进行插值 + elem.attr('d', pathAnim.step(fx.start, fx.end, fx.pos, elem.toD)); + }; + + // jQuery 1.8风格 + if (Tween) { + propHooks.d = { + set: dSetter + }; + // 1.8之前的版本 + } else { + // 动画路径 + Step.d = dSetter; + } + + /** + * 用于遍历数组的实用方法。参数与jQuery的顺序相反。 + * @param {Array} arr 要遍历的数组 + * @param {Function} fn 遍历每个元素时调用的函数 + */ + this.each = Array.prototype.forEach? + function (arr, fn) { + return Array.prototype.forEach.call(arr, fn); + + } : + function (arr, fn) { + var i, + len = arr.length; + for (i = 0; i < len; i++) { + if (fn.call(arr[i], arr[i], i, arr) === false) { + return i; + } + } + }; + + /** + * 在相应框架中注册Highcharts作为插件 + */ + $.fn.highcharts = function () { + var constr = 'Chart', + args = arguments, + options, + ret, + chart; + + if (this[0]) { + + if (isString(args[0])) { + constr = args[0]; + args = Array.prototype.slice.call(args, 1); + } + options = args[0]; + + // 创建图表 + if (options!== UNDEFINED) { + /*jslint unused:false*/ + options.chart = options.chart || {}; + options.chart.renderTo = this[0]; + chart = new Highcharts[constr](options, args[1]); + ret = this; + /*jslint unused:true*/ + } + + // + + return ret; + }; + + }, + + /** + * 下载脚本并在完成后执行回调函数 + * @param {String} scriptLocation 脚本的URL地址 + * @param {Function} callback 脚本下载完成后的回调函数 + */ + getScript: $.getScript, + + /** + * 返回元素在数组中的索引,如果元素不在数组中则返回 -1 + */ + inArray: $.inArray, + + /** + * 直接调用 jQuery 方法,在 MooTools 和 Prototype 适配器中,需要为每个方法单独实现 + * @param {Object} elem HTML 元素 + * @param {String} method 要在包装元素上调用的方法名 + */ + adapterRun: function (elem, method) { + // 使用 jQuery 包装元素并调用指定的方法 + return $(elem)[method](); + }, + + /** + * 过滤数组,返回满足条件的元素组成的新数组 + */ + grep: $.grep, + + /** + * 对数组进行映射操作,返回一个新数组,新数组中的元素是原数组元素经过映射函数处理后的结果 + * @param {Array} arr 要进行映射操作的数组 + * @param {Function} fn 映射函数,接收数组元素、索引和数组本身作为参数 + */ + map: function (arr, fn) { + // 用于存储映射结果的数组 + var results = [], + i = 0, + // 获取数组的长度 + len = arr.length; + // 遍历数组 + for (; i < len; i++) { + // 调用映射函数处理数组元素,并将结果存储到 results 数组中 + results[i] = fn.call(arr[i], arr[i], i, arr); + } + // 返回映射后的结果数组 + return results; + }, + + /** + * 获取元素相对于页面左上角的位置 + * @param {Object} el HTML 元素 + */ + offset: function (el) { + // 使用 jQuery 的 offset 方法获取元素相对于页面的位置 + return $(el).offset(); + }, + + /** + * 为元素添加事件监听器 + * @param {Object} el HTML 元素或自定义对象 + * @param {String} event 事件类型 + * @param {Function} fn 事件处理函数 + */ + addEvent: function (el, event, fn) { + // 使用 jQuery 的 bind 方法为元素绑定事件 + $(el).bind(event, fn); + }, + + /** + * 移除通过 addEvent 添加的事件监听器 + * @param {Object} el 绑定事件的对象 + * @param {String} eventType 事件类型,留空则移除所有事件 + * @param {Function} handler 要移除的事件处理函数 + */ + removeEvent: function (el, eventType, handler) { + // 解决 jQuery 解绑自定义事件时的问题 + // http://forum.jQuery.com/topic/javascript-error-when-unbinding-a-custom-event-using-jQuery-1-4-2 + var func = doc.removeEventListener? 'removeEventListener' : 'detachEvent'; + if (doc[func] && el &&!el[func]) { + // 为元素添加一个空的解绑方法,避免报错 + el[func] = function () {}; + } + // 使用 jQuery 的 unbind 方法移除事件监听器 + $(el).unbind(eventType, handler); + }, + + /** + * 在自定义对象上触发一个事件 + * @param {Object} el 触发事件的对象 + * @param {String} type 事件类型 + * @param {Object} eventArguments 事件参数 + * @param {Function} defaultFunction 事件默认行为的函数 + */ + fireEvent: function (el, type, eventArguments, defaultFunction) { + // 创建一个 jQuery 事件对象 + var event = $.Event(type), + detachedType = 'detached' + type, + defaultPrevented; + + // 移除 Chrome 中访问 returnValue、layerX 和 layerY 时的警告 + // 虽然 Highcharts 从不使用这些属性,但 Chrome 会将它们包含在默认点击事件中 + // 并且在下面的 extend 语句中复制它们时会引发警告 + if (!isIE && eventArguments) { + delete eventArguments.layerX; + delete eventArguments.layerY; + delete eventArguments.returnValue; + } + + // 将事件参数扩展到事件对象中 + extend(event, eventArguments); + + // 防止 jQuery 触发与事件同名的对象方法 + // 例如,如果事件是 'select',jQuery 会尝试调用 el.select 方法,从而导致循环 + if (el[type]) { + el[detachedType] = el[type]; + el[type] = null; + } + + // 包装 preventDefault 和 stopPropagation 方法,以防止在非 DOM 对象上取消事件时出现 JS 错误 + $.each(['preventDefault', 'stopPropagation'], function (i, fn) { + var base = event[fn]; + event[fn] = function () { + try { + base.call(event); + } catch (e) { + if (fn === 'preventDefault') { + defaultPrevented = true; + } + } + }; + }); + + // 触发事件 + $(el).trigger(event); + + // 恢复被移除的方法 + if (el[detachedType]) { + el[type] = el[detachedType]; + el[detachedType] = null; + } + + // 如果事件未被阻止默认行为,并且有默认函数,则执行默认函数 + if (defaultFunction &&!event.isDefaultPrevented() &&!defaultPrevented) { + defaultFunction(event); + } + }, + + /** + * 处理鼠标事件,确保事件对象包含必要的属性 + * @param {Object} e 鼠标事件对象 + */ + washMouseEvent: function (e) { + // 获取原始事件对象 + var ret = e.originalEvent || e; + + // 如果事件对象中没有 pageX 属性(IE8 需要),则从 jQuery 事件对象中获取 + if (ret.pageX === UNDEFINED) { + ret.pageX = e.pageX; + ret.pageY = e.pageY; + } + + return ret; + }, + + /** + * 对 HTML 元素或 SVG 元素包装器进行动画处理 + * @param {Object} el 要进行动画处理的元素 + * @param {Object} params 动画参数,如样式属性的目标值 + * @param {Object} options jQuery 风格的动画选项,如持续时间、缓动函数、回调函数 + */ + animate: function (el, params, options) { + // 使用 jQuery 包装元素 + var $el = $(el); + // 如果元素没有 style 属性,则创建一个空对象 + if (!el.style) { + el.style = {}; + } + // 如果要动画的属性包含路径定义 'd' + if (params.d) { + // 保存路径的目标值 + el.toD = params.d; + // 因为在 jQuery 中,动画到数组有不同的含义,所以将 'd' 属性设为 1 + params.d = 1; + } + + // 停止当前正在运行的动画 + $el.stop(); + // 如果要动画的属性包含 'opacity' 且元素是 SVG 元素包装器 + if (params.opacity!== UNDEFINED && el.attr) { + // 强制 jQuery 使用与宽度和高度相同的逻辑 + params.opacity += 'px'; + } + // 开始动画 + $el.animate(params, options); + }, + + /** + * 停止正在运行的动画 + * @param {Object} el 要停止动画的元素 + */ + stop: function (el) { + // 使用 jQuery 的 stop 方法停止元素的动画 + $(el).stop(); + } + }); +// 自执行函数,传入 jQuery 对象 + }(win.jQuery)); + +// 检查在本文件之前是否定义了自定义的 HighchartsAdapter + var globalAdapter = win.HighchartsAdapter, + adapter = globalAdapter || {}; + +// 初始化适配器 + if (globalAdapter) { + // 调用适配器的 init 方法进行初始化 + globalAdapter.init.call(globalAdapter, pathAnim); + } + +// 实用函数。如果 HighchartsAdapter 未定义,adapter 是一个空对象 +// 所有实用函数都将为 null。在这种情况下,它们将由下面的默认适配器填充 + var adapterRun = adapter.adapterRun, + getScript = adapter.getScript, + inArray = adapter.inArray, + each = adapter.each, + grep = adapter.grep, + offset = adapter.offset, + map = adapter.map, + addEvent = adapter.addEvent, + removeEvent = adapter.removeEvent, + fireEvent = adapter.fireEvent, + washMouseEvent = adapter.washMouseEvent, + animate = adapter.animate, + stop = adapter.stop; + + /* **************************************************************************** + * Handle the options * + *****************************************************************************/ +// 定义默认的标签选项 + var defaultLabelOptions = { + enabled: true, + // rotation: 0, + // align: 'center', + x: 0, + y: 15, + /*formatter: function () { + return this.value; + },*/ + style: { + color: '#606060', + cursor: 'default', + fontSize: '11px' + } + }; + +// 定义默认的配置选项 + defaultOptions = { + // 图表默认颜色数组 + colors: ['#7cb5ec', '#434348', '#90ed7d', '#f7a35c', + '#8085e9', '#f15c80', '#e4d354', '#8085e8', '#8d4653', '#91e8e1'], + // 图表标记符号数组 + symbols: ['circle', 'diamond', 'square', 'triangle', 'triangle-down'], + lang: { + loading: '加载中...', + months: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', + '八月', '九月', '十月', '十一月', '十二月'], + shortMonths: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', + '八月', '九月', '十月', '十一月', '十二月'], + weekdays: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'], + decimalPoint: '.', + numericSymbols: ['k', 'M', 'G', 'T', 'P', 'E'], // 轴标签中使用的 SI 前缀 + resetZoom: 'Reset zoom', + resetZoomTitle: '设置比例为1:1', + thousandsSep: ',' + }, + global: { + useUTC: true, + //timezoneOffset: 0, + canvasToolsURL: 'http://code.highcharts.com/stock/2.0.3/modules/canvas-tools.js', + VMLRadialGradientURL: 'http://code.highcharts.com/stock/2.0.3/gfx/vml-radial-gradient.png' + }, + chart: { + //animation: true, + //alignTicks: false, + //reflow: true, + //className: null, + //events: { load, selection }, + //margin: [null], + //marginTop: null, + //marginRight: null, + //marginBottom: null, + //marginLeft: null, + borderColor: '#4572A7', + //borderWidth: 0, + borderRadius: 0, + defaultSeriesType: 'line', + ignoreHiddenSeries: true, + //inverted: false, + //shadow: false, + spacing: [10, 10, 15, 10], + //spacingTop: 10, + //spacingRight: 10, + //spacingBottom: 15, + //spacingLeft: 10, + //style: { + // fontFamily: '"Lucida Grande", "Lucida Sans Unicode", Verdana, Arial, Helvetica, sans-serif', // 默认字体 + // fontSize: '12px' + //}, + backgroundColor: '#FFFFFF', + //plotBackgroundColor: null, + plotBorderColor: '#C0C0C0', + //plotBorderWidth: 0, + //plotShadow: false, + //zoomType: '' + resetZoomButton: { + theme: { + zIndex: 20 + }, + position: { + align: 'right', + x: -10, + //verticalAlign: 'top', + y: 10 + } + // relativeTo: 'plot' + } + }, + title: { + text: 'Chart title', + align: 'center', + // floating: false, + margin: 15, + // x: 0, + // verticalAlign: 'top', + // y: null, + style: { + color: '#333333', + fontSize: '18px' + } + }, + subtitle: { + text: '', + align: 'center', + // floating: false + // x: 0, + // verticalAlign: 'top', + // y: null, + style: { + color: '#555555' + } + }, + + plotOptions: { + line: { // 基本系列选项 + allowPointSelect: false, + showCheckbox: false, + animation: { + duration: 1000 + }, + //connectNulls: false, + //cursor: 'default', + //clip: true, + //dashStyle: null, + //enableMouseTracking: true, + events: {}, + //legendIndex: 0, + //linecap: 'round', + lineWidth: 2, + //shadow: false, + // stacking: null, + marker: { + //enabled: true, + //symbol: null, + lineWidth: 0, + radius: 4, + lineColor: '#FFFFFF', + //fillColor: null, + states: { // 单个点的状态 + hover: { + enabled: true, + lineWidthPlus: 1, + radiusPlus: 2 + }, + select: { + fillColor: '#FFFFFF', + lineColor: '#000000', + lineWidth: 2 + } + } + }, + point: { + events: {} + }, + dataLabels: merge(defaultLabelOptions, { + align: 'center', + //defer: true, + enabled: false, + formatter: function () { + return this.y === null? '' : numberFormat(this.y, -1); + }, + verticalAlign: 'bottom', // 在单个点上方 + y: 0 + // backgroundColor: undefined, + // borderColor: undefined, + // borderRadius: undefined, + // borderWidth: undefined, + // padding: 3, + // shadow: false + }), + cropThreshold: 300, // 当点数少于此值时,在绘图区域外绘制点 + pointRange: 0, + //pointStart: 0, + //pointInterval: 1, + //showInLegend: null, // 自动:独立系列为 true,链接系列为 false + states: { // 整个系列的状态 + hover: { + //enabled: false, + lineWidthPlus: 1, + marker: { + // lineWidth: base + 1, + // radius: base + 1 + }, + halo: { + size: 10, + opacity: 0.25 + } + }, + select: { + marker: {} + } + }, + stickyTracking: true, + //tooltip: { + //pointFormat: '\u25CF {series.name}: {point.y}' + //valueDecimals: null, + //xDateFormat: '%A, %b %e, %Y', + //valuePrefix: '', + //ySuffix: '' + //} + turboThreshold: 1000 + // zIndex: null + } + }, + labels: { + //items: [], + style: { + //font: defaultFont, + position: ABSOLUTE, + color: '#3E576F' + } + }, + legend: { + enabled: true, + align: 'center', + //floating: false, + layout: 'horizontal', + labelFormatter: function () { + return this.name; + }, + //borderWidth: 0, + borderColor: '#909090', + borderRadius: 0, + navigation: { + // animation: true, + activeColor: '#274b6d', + // arrowSize: 12 + inactiveColor: '#CCC' + // style: {} // 文本样式 + }, + // margin: 20, + // reversed: false, + shadow: false, + // backgroundColor: null, + /*style: { + padding: '5px' + },*/ + itemStyle: { + color: '#333333', + fontSize: '12px', + fontWeight: 'bold' + }, + itemHoverStyle: { + //cursor: 'pointer', removed as of #601 + color: '#000' + }, + itemHiddenStyle: { + color: '#CCC' + }, + itemCheckboxStyle: { + position: ABSOLUTE, + width: '13px', // 为了 IE 精度 + height: '13px' + }, + // itemWidth: undefined, + // symbolRadius: 0, + // symbolWidth: 16, + symbolPadding: 5, + verticalAlign: 'bottom', + // width: undefined, + x: 0, + y: 0, + title: { + //text: null, + style: { + fontWeight: 'bold' + } + } + }, + + loading: { + // hideDuration: 100, + labelStyle: { + fontWeight: 'bold', + position: RELATIVE, + top: '45%' + }, + // showDuration: 0, + style: { + position: ABSOLUTE, + backgroundColor: 'white', + opacity: 0.5, + textAlign: 'center' + } + }, + + tooltip: { + enabled: true, + animation: hasSVG, + //crosshairs: null, + backgroundColor: 'rgba(249, 249, 249, .85)', + borderWidth: 1, + borderRadius: 3, + dateTimeLabelFormats: { + millisecond: '%A, %b %e, %H:%M:%S.%L', + second: '%A, %b %e, %H:%M:%S', + minute: '%A, %b %e, %H:%M', + hour: '%A, %b %e, %H:%M', + day: '%A, %b %e, %Y', + week: 'Week from %A, %b %e, %Y', + month: '%B %Y', + year: '%Y' + }, + //formatter: defaultFormatter, + headerFormat: '{point.key}
', + pointFormat: '\u25CF {series.name}: {point.y}
', + shadow: true, + //shape: 'callout', + //shared: false, + snap: isTouchDevice ? 25 : 10, + style: { + color: '#333333', + cursor: 'default', + fontSize: '12px', + padding: '8px', + whiteSpace: 'nowrap' + } + //xDateFormat: '%A, %b %e, %Y', + //valueDecimals: null, + //valuePrefix: '', + //valueSuffix: '' + }, + + credits: { + enabled: true, + text: 'Highcharts.com', + href: 'http://www.highcharts.com', + position: { + align: 'right', + x: -10, + verticalAlign: 'bottom', + y: -5 + }, + style: { + cursor: 'pointer', + color: '#909090', + fontSize: '9px' + } + } +}; + + + + +// Series defaults +var defaultPlotOptions = defaultOptions.plotOptions, + defaultSeriesOptions = defaultPlotOptions.line; + +// set the default time methods +setTimeMethods(); + + + +/** + * Set the time methods globally based on the useUTC option. Time method can be either + * local time or UTC (default). + */ +function setTimeMethods() { + var useUTC = defaultOptions.global.useUTC, + GET = useUTC ? 'getUTC' : 'get', + SET = useUTC ? 'setUTC' : 'set'; + + + timezoneOffset = ((useUTC && defaultOptions.global.timezoneOffset) || 0) * 60000; + makeTime = useUTC ? Date.UTC : function (year, month, date, hours, minutes, seconds) { + return new Date( + year, + month, + pick(date, 1), + pick(hours, 0), + pick(minutes, 0), + pick(seconds, 0) + ).getTime(); + }; + getMinutes = GET + 'Minutes'; + getHours = GET + 'Hours'; + getDay = GET + 'Day'; + getDate = GET + 'Date'; + getMonth = GET + 'Month'; + getFullYear = GET + 'FullYear'; + setMinutes = SET + 'Minutes'; + setHours = SET + 'Hours'; + setDate = SET + 'Date'; + setMonth = SET + 'Month'; + setFullYear = SET + 'FullYear'; + +} + +/** + * Merge the default options with custom options and return the new options structure + * @param {Object} options The new custom options + */ +function setOptions(options) { + + // Copy in the default options + defaultOptions = merge(true, defaultOptions, options); + + // Apply UTC + setTimeMethods(); + + return defaultOptions; +} + +/** + * Get the updated default options. Until 3.0.7, merely exposing defaultOptions for outside modules + * wasn't enough because the setOptions method created a new object. + */ +function getOptions() { + return defaultOptions; +} + + +/** + * Handle color operations. The object methods are chainable. + * @param {String} input The input color in either rbga or hex format + */ +var rgbaRegEx = /rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]?(?:\.[0-9]+)?)\s*\)/, + hexRegEx = /#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/, + rgbRegEx = /rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/; + +var Color = function (input) { + // declare variables + var rgba = [], result, stops; + + /** + * Parse the input color to rgba array + * @param {String} input + */ + function init(input) { + + // Gradients + if (input && input.stops) { + stops = map(input.stops, function (stop) { + return Color(stop[1]); + }); + + // Solid colors + } else { + // rgba + result = rgbaRegEx.exec(input); + if (result) { + rgba = [pInt(result[1]), pInt(result[2]), pInt(result[3]), parseFloat(result[4], 10)]; + } else { + // hex + result = hexRegEx.exec(input); + if (result) { + rgba = [pInt(result[1], 16), pInt(result[2], 16), pInt(result[3], 16), 1]; + } else { + // rgb + result = rgbRegEx.exec(input); + if (result) { + rgba = [pInt(result[1]), pInt(result[2]), pInt(result[3]), 1]; + } + } + } + } + + } + /** + * Return the color a specified format + * @param {String} format + */ + function get(format) { + var ret; + + if (stops) { + ret = merge(input); + ret.stops = [].concat(ret.stops); + each(stops, function (stop, i) { + ret.stops[i] = [ret.stops[i][0], stop.get(format)]; + }); + + // it's NaN if gradient colors on a column chart + } else if (rgba && !isNaN(rgba[0])) { + if (format === 'rgb') { + ret = 'rgb(' + rgba[0] + ',' + rgba[1] + ',' + rgba[2] + ')'; + } else if (format === 'a') { + ret = rgba[3]; + } else { + ret = 'rgba(' + rgba.join(',') + ')'; + } + } else { + ret = input; + } + return ret; + } + + /** + * Brighten the color + * @param {Number} alpha + */ + function brighten(alpha) { + if (stops) { + each(stops, function (stop) { + stop.brighten(alpha); + }); + + } else if (isNumber(alpha) && alpha !== 0) { + var i; + for (i = 0; i < 3; i++) { + rgba[i] += pInt(alpha * 255); + + if (rgba[i] < 0) { + rgba[i] = 0; + } + if (rgba[i] > 255) { + rgba[i] = 255; + } + } + } + return this; + } + /** + * Set the color's opacity to a given alpha value + * @param {Number} alpha + */ + function setOpacity(alpha) { + rgba[3] = alpha; + return this; + } + + // initialize: parse the input + init(input); + + // public methods + return { + get: get, + brighten: brighten, + rgba: rgba, + setOpacity: setOpacity + }; +}; + + +/** + * A wrapper object for SVG elements + */ +function SVGElement() {} + +SVGElement.prototype = { + + // Default base for animation + opacity: 1, + // For labels, these CSS properties are applied to the node directly + textProps: ['fontSize', 'fontWeight', 'fontFamily', 'color', + 'lineHeight', 'width', 'textDecoration', 'textShadow', 'HcTextStroke'], + + /** + * Initialize the SVG renderer + * @param {Object} renderer + * @param {String} nodeName + */ + init: function (renderer, nodeName) { + var wrapper = this; + wrapper.element = nodeName === 'span' ? + createElement(nodeName) : + doc.createElementNS(SVG_NS, nodeName); + wrapper.renderer = renderer; + }, + + /** + * Animate a given attribute + * @param {Object} params + * @param {Number} options The same options as in jQuery animation + * @param {Function} complete Function to perform at the end of animation + */ + animate: function (params, options, complete) { + var animOptions = pick(options, globalAnimation, true); + stop(this); // stop regardless of animation actually running, or reverting to .attr (#607) + if (animOptions) { + animOptions = merge(animOptions, {}); //#2625 + if (complete) { // allows using a callback with the global animation without overwriting it + animOptions.complete = complete; + } + animate(this, params, animOptions); + } else { + this.attr(params); + if (complete) { + complete(); + } + } + return this; + }, + + /** + * Build an SVG gradient out of a common JavaScript configuration object + */ + colorGradient: function (color, prop, elem) { + var renderer = this.renderer, + colorObject, + gradName, + gradAttr, + gradients, + gradientObject, + stops, + stopColor, + stopOpacity, + radialReference, + n, + id, + key = []; + + // Apply linear or radial gradients + if (color.linearGradient) { + gradName = 'linearGradient'; + } else if (color.radialGradient) { + gradName = 'radialGradient'; + } + + if (gradName) { + gradAttr = color[gradName]; + gradients = renderer.gradients; + stops = color.stops; + radialReference = elem.radialReference; + + // Keep < 2.2 kompatibility + if (isArray(gradAttr)) { + color[gradName] = gradAttr = { + x1: gradAttr[0], + y1: gradAttr[1], + x2: gradAttr[2], + y2: gradAttr[3], + gradientUnits: 'userSpaceOnUse' + }; + } + + // Correct the radial gradient for the radial reference system + if (gradName === 'radialGradient' && radialReference && !defined(gradAttr.gradientUnits)) { + gradAttr = merge(gradAttr, { + cx: (radialReference[0] - radialReference[2] / 2) + gradAttr.cx * radialReference[2], + cy: (radialReference[1] - radialReference[2] / 2) + gradAttr.cy * radialReference[2], + r: gradAttr.r * radialReference[2], + gradientUnits: 'userSpaceOnUse' + }); + } + + // Build the unique key to detect whether we need to create a new element (#1282) + for (n in gradAttr) { + if (n !== 'id') { + key.push(n, gradAttr[n]); + } + } + for (n in stops) { + key.push(stops[n]); + } + key = key.join(','); + + // Check if a gradient object with the same config object is created within this renderer + if (gradients[key]) { + id = gradients[key].attr('id'); + + } else { + + // Set the id and create the element + gradAttr.id = id = PREFIX + idCounter++; + gradients[key] = gradientObject = renderer.createElement(gradName) + .attr(gradAttr) + .add(renderer.defs); + + + // The gradient needs to keep a list of stops to be able to destroy them + gradientObject.stops = []; + each(stops, function (stop) { + var stopObject; + if (stop[1].indexOf('rgba') === 0) { + colorObject = Color(stop[1]); + stopColor = colorObject.get('rgb'); + stopOpacity = colorObject.get('a'); + } else { + stopColor = stop[1]; + stopOpacity = 1; + } + stopObject = renderer.createElement('stop').attr({ + offset: stop[0], + 'stop-color': stopColor, + 'stop-opacity': stopOpacity + }).add(gradientObject); + + // Add the stop element to the gradient + gradientObject.stops.push(stopObject); + }); + } + + // Set the reference to the gradient object + elem.setAttribute(prop, 'url(' + renderer.url + '#' + id + ')'); + } + }, + + /** + * Set or get a given attribute + * @param {Object|String} hash + * @param {Mixed|Undefined} val + */ + attr: function (hash, val) { + var key, + value, + element = this.element, + hasSetSymbolSize, + ret = this, + skipAttr; + + // single key-value pair + if (typeof hash === 'string' && val !== UNDEFINED) { + key = hash; + hash = {}; + hash[key] = val; + } + + // used as a getter: first argument is a string, second is undefined + if (typeof hash === 'string') { + ret = (this[hash + 'Getter'] || this._defaultGetter).call(this, hash, element); + + // setter + } else { + + for (key in hash) { + value = hash[key]; + skipAttr = false; + + + + if (this.symbolName && /^(x|y|width|height|r|start|end|innerR|anchorX|anchorY)/.test(key)) { + if (!hasSetSymbolSize) { + this.symbolAttr(hash); + hasSetSymbolSize = true; + } + skipAttr = true; + } + + if (this.rotation && (key === 'x' || key === 'y')) { + this.doTransform = true; + } + + if (!skipAttr) { + (this[key + 'Setter'] || this._defaultSetter).call(this, value, key, element); + } + + // Let the shadow follow the main element + if (this.shadows && /^(width|height|visibility|x|y|d|transform|cx|cy|r)$/.test(key)) { + this.updateShadows(key, value); + } + } + + // Update transform. Do this outside the loop to prevent redundant updating for batch setting + // of attributes. + if (this.doTransform) { + this.updateTransform(); + this.doTransform = false; + } + + } + + return ret; + }, + + updateShadows: function (key, value) { + var shadows = this.shadows, + i = shadows.length; + while (i--) { + shadows[i].setAttribute( + key, + key === 'height' ? + mathMax(value - (shadows[i].cutHeight || 0), 0) : + key === 'd' ? this.d : value + ); + } + }, + + /** + * Add a class name to an element + */ + addClass: function (className) { + var element = this.element, + currentClassName = attr(element, 'class') || ''; + + if (currentClassName.indexOf(className) === -1) { + attr(element, 'class', currentClassName + ' ' + className); + } + return this; + }, + /* hasClass and removeClass are not (yet) needed + hasClass: function (className) { + return attr(this.element, 'class').indexOf(className) !== -1; + }, + removeClass: function (className) { + attr(this.element, 'class', attr(this.element, 'class').replace(className, '')); + return this; + }, + */ + + /** + * If one of the symbol size affecting parameters are changed, + * check all the others only once for each call to an element's + * .attr() method + * @param {Object} hash + */ + symbolAttr: function (hash) { + var wrapper = this; + + each(['x', 'y', 'r', 'start', 'end', 'width', 'height', 'innerR', 'anchorX', 'anchorY'], function (key) { + wrapper[key] = pick(hash[key], wrapper[key]); + }); + + wrapper.attr({ + d: wrapper.renderer.symbols[wrapper.symbolName]( + wrapper.x, + wrapper.y, + wrapper.width, + wrapper.height, + wrapper + ) + }); + }, + + /** + * Apply a clipping path to this object + * @param {String} id + */ + clip: function (clipRect) { + return this.attr('clip-path', clipRect ? 'url(' + this.renderer.url + '#' + clipRect.id + ')' : NONE); + }, + + /** + * Calculate the coordinates needed for drawing a rectangle crisply and return the + * calculated attributes + * @param {Number} strokeWidth + * @param {Number} x + * @param {Number} y + * @param {Number} width + * @param {Number} height + */ + crisp: function (rect) { + + var wrapper = this, + key, + attribs = {}, + normalizer, + strokeWidth = rect.strokeWidth || wrapper.strokeWidth || 0; + + normalizer = mathRound(strokeWidth) % 2 / 2; // mathRound because strokeWidth can sometimes have roundoff errors + + // normalize for crisp edges + rect.x = mathFloor(rect.x || wrapper.x || 0) + normalizer; + rect.y = mathFloor(rect.y || wrapper.y || 0) + normalizer; + rect.width = mathFloor((rect.width || wrapper.width || 0) - 2 * normalizer); + rect.height = mathFloor((rect.height || wrapper.height || 0) - 2 * normalizer); + rect.strokeWidth = strokeWidth; + + for (key in rect) { + if (wrapper[key] !== rect[key]) { // only set attribute if changed + wrapper[key] = attribs[key] = rect[key]; + } + } + + return attribs; + }, + + /** + * Set styles for the element + * @param {Object} styles + */ + css: function (styles) { + var elemWrapper = this, + oldStyles = elemWrapper.styles, + newStyles = {}, + elem = elemWrapper.element, + textWidth, + n, + serializedCss = '', + hyphenate, + hasNew = !oldStyles; + + // convert legacy + if (styles && styles.color) { + styles.fill = styles.color; + } + + // Filter out existing styles to increase performance (#2640) + if (oldStyles) { + for (n in styles) { + if (styles[n] !== oldStyles[n]) { + newStyles[n] = styles[n]; + hasNew = true; + } + } + } + if (hasNew) { + textWidth = elemWrapper.textWidth = styles && styles.width && elem.nodeName.toLowerCase() === 'text' && pInt(styles.width); + + // Merge the new styles with the old ones + if (oldStyles) { + styles = extend( + oldStyles, + newStyles + ); + } + + // store object + elemWrapper.styles = styles; + + if (textWidth && (useCanVG || (!hasSVG && elemWrapper.renderer.forExport))) { + delete styles.width; + } + + // serialize and set style attribute + if (isIE && !hasSVG) { + css(elemWrapper.element, styles); + } else { + /*jslint unparam: true*/ + hyphenate = function (a, b) { return '-' + b.toLowerCase(); }; + /*jslint unparam: false*/ + for (n in styles) { + serializedCss += n.replace(/([A-Z])/g, hyphenate) + ':' + styles[n] + ';'; + } + attr(elem, 'style', serializedCss); // #1881 + } + + + // re-build text + if (textWidth && elemWrapper.added) { + elemWrapper.renderer.buildText(elemWrapper); + } + } + + return elemWrapper; + }, + + /** + * Add an event listener + * @param {String} eventType + * @param {Function} handler + */ + on: function (eventType, handler) { + var svgElement = this, + element = svgElement.element; + + // touch + if (hasTouch && eventType === 'click') { + element.ontouchstart = function (e) { + svgElement.touchEventFired = Date.now(); + e.preventDefault(); + handler.call(element, e); + }; + element.onclick = function (e) { + if (userAgent.indexOf('Android') === -1 || Date.now() - (svgElement.touchEventFired || 0) > 1100) { // #2269 + handler.call(element, e); + } + }; + } else { + // simplest possible event model for internal use + element['on' + eventType] = handler; + } + return this; + }, + + /** + * Set the coordinates needed to draw a consistent radial gradient across + * pie slices regardless of positioning inside the chart. The format is + * [centerX, centerY, diameter] in pixels. + */ + setRadialReference: function (coordinates) { + this.element.radialReference = coordinates; + return this; + }, + + /** + * Move an object and its children by x and y values + * @param {Number} x + * @param {Number} y + */ + translate: function (x, y) { + return this.attr({ + translateX: x, + translateY: y + }); + }, + + /** + * Invert a group, rotate and flip + */ + invert: function () { + var wrapper = this; + wrapper.inverted = true; + wrapper.updateTransform(); + return wrapper; + }, + + /** + * Private method to update the transform attribute based on internal + * properties + */ + updateTransform: function () { + var wrapper = this, + translateX = wrapper.translateX || 0, + translateY = wrapper.translateY || 0, + scaleX = wrapper.scaleX, + scaleY = wrapper.scaleY, + inverted = wrapper.inverted, + rotation = wrapper.rotation, + element = wrapper.element, + transform; + + // flipping affects translate as adjustment for flipping around the group's axis + if (inverted) { + translateX += wrapper.attr('width'); + translateY += wrapper.attr('height'); + } + + // Apply translate. Nearly all transformed elements have translation, so instead + // of checking for translate = 0, do it always (#1767, #1846). + transform = ['translate(' + translateX + ',' + translateY + ')']; + + // apply rotation + if (inverted) { + transform.push('rotate(90) scale(-1,1)'); + } else if (rotation) { // text rotation + transform.push('rotate(' + rotation + ' ' + (element.getAttribute('x') || 0) + ' ' + (element.getAttribute('y') || 0) + ')'); + } + + // apply scale + if (defined(scaleX) || defined(scaleY)) { + transform.push('scale(' + pick(scaleX, 1) + ' ' + pick(scaleY, 1) + ')'); + } + + if (transform.length) { + element.setAttribute('transform', transform.join(' ')); + } + }, + /** + * Bring the element to the front + */ + toFront: function () { + var element = this.element; + element.parentNode.appendChild(element); + return this; + }, + + + /** + * Break down alignment options like align, verticalAlign, x and y + * to x and y relative to the chart. + * + * @param {Object} alignOptions + * @param {Boolean} alignByTranslate + * @param {String[Object} box The box to align to, needs a width and height. When the + * box is a string, it refers to an object in the Renderer. For example, when + * box is 'spacingBox', it refers to Renderer.spacingBox which holds width, height + * x and y properties. + * + */ + align: function (alignOptions, alignByTranslate, box) { + var align, + vAlign, + x, + y, + attribs = {}, + alignTo, + renderer = this.renderer, + alignedObjects = renderer.alignedObjects; + + // First call on instanciate + if (alignOptions) { + this.alignOptions = alignOptions; + this.alignByTranslate = alignByTranslate; + if (!box || isString(box)) { // boxes other than renderer handle this internally + this.alignTo = alignTo = box || 'renderer'; + erase(alignedObjects, this); // prevent duplicates, like legendGroup after resize + alignedObjects.push(this); + box = null; // reassign it below + } + + // When called on resize, no arguments are supplied + } else { + alignOptions = this.alignOptions; + alignByTranslate = this.alignByTranslate; + alignTo = this.alignTo; + } + + box = pick(box, renderer[alignTo], renderer); + + // Assign variables + align = alignOptions.align; + vAlign = alignOptions.verticalAlign; + x = (box.x || 0) + (alignOptions.x || 0); // default: left align + y = (box.y || 0) + (alignOptions.y || 0); // default: top align + + // Align + if (align === 'right' || align === 'center') { + x += (box.width - (alignOptions.width || 0)) / + { right: 1, center: 2 }[align]; + } + attribs[alignByTranslate ? 'translateX' : 'x'] = mathRound(x); + + + // Vertical align + if (vAlign === 'bottom' || vAlign === 'middle') { + y += (box.height - (alignOptions.height || 0)) / + ({ bottom: 1, middle: 2 }[vAlign] || 1); + + } + attribs[alignByTranslate ? 'translateY' : 'y'] = mathRound(y); + + // Animate only if already placed + this[this.placed ? 'animate' : 'attr'](attribs); + this.placed = true; + this.alignAttr = attribs; + + return this; + }, + + /** + * Get the bounding box (width, height, x and y) for the element + */ + getBBox: function () { + var wrapper = this, + bBox = wrapper.bBox, + renderer = wrapper.renderer, + width, + height, + rotation = wrapper.rotation, + element = wrapper.element, + styles = wrapper.styles, + rad = rotation * deg2rad, + textStr = wrapper.textStr, + cacheKey; + + // Since numbers are monospaced, and numerical labels appear a lot in a chart, + // we assume that a label of n characters has the same bounding box as others + // of the same length. + if (textStr === '' || numRegex.test(textStr)) { + cacheKey = 'num.' + textStr.toString().length + (styles ? ('|' + styles.fontSize + '|' + styles.fontFamily) : ''); + + } //else { // This code block made demo/waterfall fail, related to buildText + // Caching all strings reduces rendering time by 4-5%. + // TODO: Check how this affects places where bBox is found on the element + //cacheKey = textStr + (styles ? ('|' + styles.fontSize + '|' + styles.fontFamily) : ''); + //} + if (cacheKey) { + bBox = renderer.cache[cacheKey]; + } + + // No cache found + if (!bBox) { + + // SVG elements + if (element.namespaceURI === SVG_NS || renderer.forExport) { + try { // Fails in Firefox if the container has display: none. + + bBox = element.getBBox ? + // SVG: use extend because IE9 is not allowed to change width and height in case + // of rotation (below) + extend({}, element.getBBox()) : + // Canvas renderer and legacy IE in export mode + { + width: element.offsetWidth, + height: element.offsetHeight + }; + } catch (e) {} + + // If the bBox is not set, the try-catch block above failed. The other condition + // is for Opera that returns a width of -Infinity on hidden elements. + if (!bBox || bBox.width < 0) { + bBox = { width: 0, height: 0 }; + } + + + // VML Renderer or useHTML within SVG + } else { + + bBox = wrapper.htmlGetBBox(); + + } + + // True SVG elements as well as HTML elements in modern browsers using the .useHTML option + // need to compensated for rotation + if (renderer.isSVG) { + width = bBox.width; + height = bBox.height; + + // Workaround for wrong bounding box in IE9 and IE10 (#1101, #1505, #1669, #2568) + if (isIE && styles && styles.fontSize === '11px' && height.toPrecision(3) === '16.9') { + bBox.height = height = 14; + } + + // Adjust for rotated text + if (rotation) { + bBox.width = mathAbs(height * mathSin(rad)) + mathAbs(width * mathCos(rad)); + bBox.height = mathAbs(height * mathCos(rad)) + mathAbs(width * mathSin(rad)); + } + } + + // Cache it + wrapper.bBox = bBox; + if (cacheKey) { + renderer.cache[cacheKey] = bBox; + } + } + return bBox; + }, + + /** + * Show the element + */ + show: function (inherit) { + // IE9-11 doesn't handle visibilty:inherit well, so we remove the attribute instead (#2881) + if (inherit && this.element.namespaceURI === SVG_NS) { + this.element.removeAttribute('visibility'); + return this; + } else { + return this.attr({ visibility: inherit ? 'inherit' : VISIBLE }); + } + }, + + /** + * Hide the element + */ + hide: function () { + return this.attr({ visibility: HIDDEN }); + }, + + fadeOut: function (duration) { + var elemWrapper = this; + elemWrapper.animate({ + opacity: 0 + }, { + duration: duration || 150, + complete: function () { + elemWrapper.hide(); + } + }); + }, + + /** + * Add the element + * @param {Object|Undefined} parent Can be an element, an element wrapper or undefined + * to append the element to the renderer.box. + */ + add: function (parent) { + + var renderer = this.renderer, + parentWrapper = parent || renderer, + parentNode = parentWrapper.element || renderer.box, + childNodes, + element = this.element, + zIndex = this.zIndex, + otherElement, + otherZIndex, + i, + inserted; + + if (parent) { + this.parentGroup = parent; + } + + // mark as inverted + this.parentInverted = parent && parent.inverted; + + // build formatted text + if (this.textStr !== undefined) { + renderer.buildText(this); + } + + // mark the container as having z indexed children + if (zIndex) { + parentWrapper.handleZ = true; + zIndex = pInt(zIndex); + } + + // insert according to this and other elements' zIndex + if (parentWrapper.handleZ) { // this element or any of its siblings has a z index + childNodes = parentNode.childNodes; + for (i = 0; i < childNodes.length; i++) { + otherElement = childNodes[i]; + otherZIndex = attr(otherElement, 'zIndex'); + if (otherElement !== element && ( + // insert before the first element with a higher zIndex + pInt(otherZIndex) > zIndex || + // if no zIndex given, insert before the first element with a zIndex + (!defined(zIndex) && defined(otherZIndex)) + + )) { + parentNode.insertBefore(element, otherElement); + inserted = true; + break; + } + } + } + + // default: append at the end + if (!inserted) { + parentNode.appendChild(element); + } + + // mark as added + this.added = true; + + // fire an event for internal hooks + if (this.onAdd) { + this.onAdd(); + } + + return this; + }, + + /** + * Removes a child either by removeChild or move to garbageBin. + * Issue 490; in VML removeChild results in Orphaned nodes according to sIEve, discardElement does not. + */ + safeRemoveChild: function (element) { + var parentNode = element.parentNode; + if (parentNode) { + parentNode.removeChild(element); + } + }, + + /** + * Destroy the element and element wrapper + */ + destroy: function () { + var wrapper = this, + element = wrapper.element || {}, + shadows = wrapper.shadows, + parentToClean = wrapper.renderer.isSVG && element.nodeName === 'SPAN' && wrapper.parentGroup, + grandParent, + key, + i; + + // remove events + element.onclick = element.onmouseout = element.onmouseover = element.onmousemove = element.point = null; + stop(wrapper); // stop running animations + + if (wrapper.clipPath) { + wrapper.clipPath = wrapper.clipPath.destroy(); + } + + // Destroy stops in case this is a gradient object + if (wrapper.stops) { + for (i = 0; i < wrapper.stops.length; i++) { + wrapper.stops[i] = wrapper.stops[i].destroy(); + } + wrapper.stops = null; + } + + // remove element + wrapper.safeRemoveChild(element); + + // destroy shadows + if (shadows) { + each(shadows, function (shadow) { + wrapper.safeRemoveChild(shadow); + }); + } + + // In case of useHTML, clean up empty containers emulating SVG groups (#1960, #2393, #2697). + while (parentToClean && parentToClean.div && parentToClean.div.childNodes.length === 0) { + grandParent = parentToClean.parentGroup; + wrapper.safeRemoveChild(parentToClean.div); + delete parentToClean.div; + parentToClean = grandParent; + } + + // remove from alignObjects + if (wrapper.alignTo) { + erase(wrapper.renderer.alignedObjects, wrapper); + } + + for (key in wrapper) { + delete wrapper[key]; + } + + return null; + }, + + /** + * Add a shadow to the element. Must be done after the element is added to the DOM + * @param {Boolean|Object} shadowOptions + */ + shadow: function (shadowOptions, group, cutOff) { + var shadows = [], + i, + shadow, + element = this.element, + strokeWidth, + shadowWidth, + shadowElementOpacity, + + // compensate for inverted plot area + transform; + + + if (shadowOptions) { + shadowWidth = pick(shadowOptions.width, 3); + shadowElementOpacity = (shadowOptions.opacity || 0.15) / shadowWidth; + transform = this.parentInverted ? + '(-1,-1)' : + '(' + pick(shadowOptions.offsetX, 1) + ', ' + pick(shadowOptions.offsetY, 1) + ')'; + for (i = 1; i <= shadowWidth; i++) { + shadow = element.cloneNode(0); + strokeWidth = (shadowWidth * 2) + 1 - (2 * i); + attr(shadow, { + 'isShadow': 'true', + 'stroke': shadowOptions.color || 'black', + 'stroke-opacity': shadowElementOpacity * i, + 'stroke-width': strokeWidth, + 'transform': 'translate' + transform, + 'fill': NONE + }); + if (cutOff) { + attr(shadow, 'height', mathMax(attr(shadow, 'height') - strokeWidth, 0)); + shadow.cutHeight = strokeWidth; + } + + if (group) { + group.element.appendChild(shadow); + } else { + element.parentNode.insertBefore(shadow, element); + } + + shadows.push(shadow); + } + + this.shadows = shadows; + } + return this; + + }, + + xGetter: function (key) { + if (this.element.nodeName === 'circle') { + key = { x: 'cx', y: 'cy' }[key] || key; + } + return this._defaultGetter(key); + }, + + /** + * Get the current value of an attribute or pseudo attribute, used mainly + * for animation. + */ + _defaultGetter: function (key) { + var ret = pick(this[key], this.element ? this.element.getAttribute(key) : null, 0); + + if (/^[\-0-9\.]+$/.test(ret)) { // is numerical + ret = parseFloat(ret); + } + return ret; + }, + + + dSetter: function (value, key, element) { + if (value && value.join) { // join path + value = value.join(' '); + } + if (/(NaN| {2}|^$)/.test(value)) { + value = 'M 0 0'; + } + element.setAttribute(key, value); + + this[key] = value; + }, + dashstyleSetter: function (value) { + var i; + value = value && value.toLowerCase(); + if (value) { + value = value + .replace('shortdashdotdot', '3,1,1,1,1,1,') + .replace('shortdashdot', '3,1,1,1') + .replace('shortdot', '1,1,') + .replace('shortdash', '3,1,') + .replace('longdash', '8,3,') + .replace(/dot/g, '1,3,') + .replace('dash', '4,3,') + .replace(/,$/, '') + .replace('solid', 1) + .split(','); // ending comma + + i = value.length; + while (i--) { + value[i] = pInt(value[i]) * this['stroke-width']; + } + value = value.join(','); + this.element.setAttribute('stroke-dasharray', value); + } + }, + alignSetter: function (value) { + this.element.setAttribute('text-anchor', { left: 'start', center: 'middle', right: 'end' }[value]); + }, + opacitySetter: function (value, key, element) { + this[key] = value; + element.setAttribute(key, value); + }, + titleSetter: function (value) { + var titleNode = this.element.getElementsByTagName('title')[0]; + if (!titleNode) { + titleNode = doc.createElementNS(SVG_NS, 'title'); + this.element.appendChild(titleNode); + } + titleNode.textContent = value; + }, + textSetter: function (value) { + if (value !== this.textStr) { + // Delete bBox memo when the text changes + delete this.bBox; + + this.textStr = value; + if (this.added) { + this.renderer.buildText(this); + } + } + }, + fillSetter: function (value, key, element) { + if (typeof value === 'string') { + element.setAttribute(key, value); + } else if (value) { + this.colorGradient(value, key, element); + } + }, + zIndexSetter: function (value, key, element) { + element.setAttribute(key, value); + this[key] = value; + }, + _defaultSetter: function (value, key, element) { + element.setAttribute(key, value); + } +}; + +// Some shared setters and getters +SVGElement.prototype.yGetter = SVGElement.prototype.xGetter; +SVGElement.prototype.translateXSetter = SVGElement.prototype.translateYSetter = + SVGElement.prototype.rotationSetter = SVGElement.prototype.verticalAlignSetter = + SVGElement.prototype.scaleXSetter = SVGElement.prototype.scaleYSetter = function (value, key) { + this[key] = value; + this.doTransform = true; +}; + +// WebKit and Batik have problems with a stroke-width of zero, so in this case we remove the +// stroke attribute altogether. #1270, #1369, #3065, #3072. +SVGElement.prototype['stroke-widthSetter'] = SVGElement.prototype.strokeSetter = function (value, key, element) { + this[key] = value; + // Only apply the stroke attribute if the stroke width is defined and larger than 0 + if (this.stroke && this['stroke-width']) { + this.strokeWidth = this['stroke-width']; + SVGElement.prototype.fillSetter.call(this, this.stroke, 'stroke', element); // use prototype as instance may be overridden + element.setAttribute('stroke-width', this['stroke-width']); + this.hasStroke = true; + } else if (key === 'stroke-width' && value === 0 && this.hasStroke) { + element.removeAttribute('stroke'); + this.hasStroke = false; + } +}; + + +/** + * The default SVG renderer + */ +var SVGRenderer = function () { + this.init.apply(this, arguments); +}; +SVGRenderer.prototype = { + Element: SVGElement, + + /** + * Initialize the SVGRenderer + * @param {Object} container + * @param {Number} width + * @param {Number} height + * @param {Boolean} forExport + */ + init: function (container, width, height, style, forExport) { + var renderer = this, + loc = location, + boxWrapper, + element, + desc; + + boxWrapper = renderer.createElement('svg') + .attr({ + version: '1.1' + }) + .css(this.getStyle(style)); + element = boxWrapper.element; + container.appendChild(element); + + // For browsers other than IE, add the namespace attribute (#1978) + if (container.innerHTML.indexOf('xmlns') === -1) { + attr(element, 'xmlns', SVG_NS); + } + + // object properties + renderer.isSVG = true; + renderer.box = element; + renderer.boxWrapper = boxWrapper; + renderer.alignedObjects = []; + + // Page url used for internal references. #24, #672, #1070 + renderer.url = (isFirefox || isWebKit) && doc.getElementsByTagName('base').length ? + loc.href + .replace(/#.*?$/, '') // remove the hash + .replace(/([\('\)])/g, '\\$1') // escape parantheses and quotes + .replace(/ /g, '%20') : // replace spaces (needed for Safari only) + ''; + + // Add description + desc = this.createElement('desc').add(); + desc.element.appendChild(doc.createTextNode('Created with ' + PRODUCT + ' ' + VERSION)); + + + renderer.defs = this.createElement('defs').add(); + renderer.forExport = forExport; + renderer.gradients = {}; // Object where gradient SvgElements are stored + renderer.cache = {}; // Cache for numerical bounding boxes + + renderer.setSize(width, height, false); + + + + // Issue 110 workaround: + // In Firefox, if a div is positioned by percentage, its pixel position may land + // between pixels. The container itself doesn't display this, but an SVG element + // inside this container will be drawn at subpixel precision. In order to draw + // sharp lines, this must be compensated for. This doesn't seem to work inside + // iframes though (like in jsFiddle). + var subPixelFix, rect; + if (isFirefox && container.getBoundingClientRect) { + renderer.subPixelFix = subPixelFix = function () { + css(container, { left: 0, top: 0 }); + rect = container.getBoundingClientRect(); + css(container, { + left: (mathCeil(rect.left) - rect.left) + PX, + top: (mathCeil(rect.top) - rect.top) + PX + }); + }; + + // run the fix now + subPixelFix(); + + // run it on resize + addEvent(win, 'resize', subPixelFix); + } + }, + + getStyle: function (style) { + return (this.style = extend({ + fontFamily: '"Lucida Grande", "Lucida Sans Unicode", Arial, Helvetica, sans-serif', // default font + fontSize: '12px' + }, style)); + }, + + /** + * Detect whether the renderer is hidden. This happens when one of the parent elements + * has display: none. #608. + */ + isHidden: function () { + return !this.boxWrapper.getBBox().width; + }, + + /** + * Destroys the renderer and its allocated members. + */ + destroy: function () { + var renderer = this, + rendererDefs = renderer.defs; + renderer.box = null; + renderer.boxWrapper = renderer.boxWrapper.destroy(); + + // Call destroy on all gradient elements + destroyObjectProperties(renderer.gradients || {}); + renderer.gradients = null; + + // Defs are null in VMLRenderer + // Otherwise, destroy them here. + if (rendererDefs) { + renderer.defs = rendererDefs.destroy(); + } + + // Remove sub pixel fix handler + // We need to check that there is a handler, otherwise all functions that are registered for event 'resize' are removed + // See issue #982 + if (renderer.subPixelFix) { + removeEvent(win, 'resize', renderer.subPixelFix); + } + + renderer.alignedObjects = null; + + return null; + }, + + /** + * Create a wrapper for an SVG element + * @param {Object} nodeName + */ + createElement: function (nodeName) { + var wrapper = new this.Element(); + wrapper.init(this, nodeName); + return wrapper; + }, + + /** + * Dummy function for use in canvas renderer + */ + draw: function () {}, + + /** + * Parse a simple HTML string into SVG tspans + * + * @param {Object} textNode The parent text SVG node + */ + buildText: function (wrapper) { + var textNode = wrapper.element, + renderer = this, + forExport = renderer.forExport, + textStr = pick(wrapper.textStr, '').toString(), + hasMarkup = textStr.indexOf('<') !== -1, + lines, + childNodes = textNode.childNodes, + styleRegex, + hrefRegex, + parentX = attr(textNode, 'x'), + textStyles = wrapper.styles, + width = wrapper.textWidth, + textLineHeight = textStyles && textStyles.lineHeight, + textStroke = textStyles && textStyles.HcTextStroke, + i = childNodes.length, + getLineHeight = function (tspan) { + return textLineHeight ? + pInt(textLineHeight) : + renderer.fontMetrics( + /(px|em)$/.test(tspan && tspan.style.fontSize) ? + tspan.style.fontSize : + ((textStyles && textStyles.fontSize) || renderer.style.fontSize || 12), + tspan + ).h; + }; + + /// remove old text + while (i--) { + textNode.removeChild(childNodes[i]); + } + + // Skip tspans, add text directly to text node. The forceTSpan is a hook + // used in text outline hack. + if (!hasMarkup && !textStroke && textStr.indexOf(' ') === -1) { + textNode.appendChild(doc.createTextNode(textStr)); + return; + + // Complex strings, add more logic + } else { + + styleRegex = /<.*style="([^"]+)".*>/; + hrefRegex = /<.*href="(http[^"]+)".*>/; + + if (width && !wrapper.added) { + this.box.appendChild(textNode); // attach it to the DOM to read offset width + } + + if (hasMarkup) { + lines = textStr + .replace(/<(b|strong)>/g, '') + .replace(/<(i|em)>/g, '') + .replace(//g, '') + .split(//g); + + } else { + lines = [textStr]; + } + + + // remove empty line at end + if (lines[lines.length - 1] === '') { + lines.pop(); + } + + + // build the lines + each(lines, function (line, lineNo) { + var spans, spanNo = 0; + + line = line.replace(//g, '|||'); + spans = line.split('|||'); + + each(spans, function (span) { + if (span !== '' || spans.length === 1) { + var attributes = {}, + tspan = doc.createElementNS(SVG_NS, 'tspan'), + spanStyle; // #390 + if (styleRegex.test(span)) { + spanStyle = span.match(styleRegex)[1].replace(/(;| |^)color([ :])/, '$1fill$2'); + attr(tspan, 'style', spanStyle); + } + if (hrefRegex.test(span) && !forExport) { // Not for export - #1529 + attr(tspan, 'onclick', 'location.href=\"' + span.match(hrefRegex)[1] + '\"'); + css(tspan, { cursor: 'pointer' }); + } + + span = (span.replace(/<(.|\n)*?>/g, '') || ' ') + .replace(/</g, '<') + .replace(/>/g, '>'); + + // Nested tags aren't supported, and cause crash in Safari (#1596) + if (span !== ' ') { + + // add the text node + tspan.appendChild(doc.createTextNode(span)); + + if (!spanNo) { // first span in a line, align it to the left + if (lineNo && parentX !== null) { + attributes.x = parentX; + } + } else { + attributes.dx = 0; // #16 + } + + // add attributes + attr(tspan, attributes); + + // Append it + textNode.appendChild(tspan); + + // first span on subsequent line, add the line height + if (!spanNo && lineNo) { + + // allow getting the right offset height in exporting in IE + if (!hasSVG && forExport) { + css(tspan, { display: 'block' }); + } + + // Set the line height based on the font size of either + // the text element or the tspan element + attr( + tspan, + 'dy', + getLineHeight(tspan) + ); + } + + // check width and apply soft breaks + if (width) { + var words = span.replace(/([^\^])-/g, '$1- ').split(' '), // #1273 + hasWhiteSpace = spans.length > 1 || (words.length > 1 && textStyles.whiteSpace !== 'nowrap'), + tooLong, + actualWidth, + hcHeight = textStyles.HcHeight, + rest = [], + dy = getLineHeight(tspan), + softLineNo = 1, + bBox; + + while (hasWhiteSpace && (words.length || rest.length)) { + delete wrapper.bBox; // delete cache + bBox = wrapper.getBBox(); + actualWidth = bBox.width; + + // Old IE cannot measure the actualWidth for SVG elements (#2314) + if (!hasSVG && renderer.forExport) { + actualWidth = renderer.measureSpanWidth(tspan.firstChild.data, wrapper.styles); + } + + tooLong = actualWidth > width; + if (!tooLong || words.length === 1) { // new line needed + words = rest; + rest = []; + if (words.length) { + softLineNo++; + if (hcHeight && softLineNo * dy > hcHeight) { + words = ['...']; + wrapper.attr('title', wrapper.textStr); + } else { + + tspan = doc.createElementNS(SVG_NS, 'tspan'); + attr(tspan, { + dy: dy, + x: parentX + }); + if (spanStyle) { // #390 + attr(tspan, 'style', spanStyle); + } + textNode.appendChild(tspan); + } + } + if (actualWidth > width) { // a single word is pressing it out + width = actualWidth; + } + } else { // append to existing line tspan + tspan.removeChild(tspan.firstChild); + rest.unshift(words.pop()); + } + if (words.length) { + tspan.appendChild(doc.createTextNode(words.join(' ').replace(/- /g, '-'))); + } + } + } + + spanNo++; + } + } + }); + }); + } + }, + + /** + * Create a button with preset states + * @param {String} text + * @param {Number} x + * @param {Number} y + * @param {Function} callback + * @param {Object} normalState + * @param {Object} hoverState + * @param {Object} pressedState + */ + button: function (text, x, y, callback, normalState, hoverState, pressedState, disabledState, shape) { + var label = this.label(text, x, y, shape, null, null, null, null, 'button'), + curState = 0, + stateOptions, + stateStyle, + normalStyle, + hoverStyle, + pressedStyle, + disabledStyle, + verticalGradient = { x1: 0, y1: 0, x2: 0, y2: 1 }; + + // Normal state - prepare the attributes + normalState = merge({ + 'stroke-width': 1, + stroke: '#CCCCCC', + fill: { + linearGradient: verticalGradient, + stops: [ + [0, '#FEFEFE'], + [1, '#F6F6F6'] + ] + }, + r: 2, + padding: 5, + style: { + color: 'black' + } + }, normalState); + normalStyle = normalState.style; + delete normalState.style; + + // Hover state + hoverState = merge(normalState, { + stroke: '#68A', + fill: { + linearGradient: verticalGradient, + stops: [ + [0, '#FFF'], + [1, '#ACF'] + ] + } + }, hoverState); + hoverStyle = hoverState.style; + delete hoverState.style; + + // Pressed state + pressedState = merge(normalState, { + stroke: '#68A', + fill: { + linearGradient: verticalGradient, + stops: [ + [0, '#9BD'], + [1, '#CDF'] + ] + } + }, pressedState); + pressedStyle = pressedState.style; + delete pressedState.style; + + // Disabled state + disabledState = merge(normalState, { + style: { + color: '#CCC' + } + }, disabledState); + disabledStyle = disabledState.style; + delete disabledState.style; + + // Add the events. IE9 and IE10 need mouseover and mouseout to funciton (#667). + addEvent(label.element, isIE ? 'mouseover' : 'mouseenter', function () { + if (curState !== 3) { + label.attr(hoverState) + .css(hoverStyle); + } + }); + addEvent(label.element, isIE ? 'mouseout' : 'mouseleave', function () { + if (curState !== 3) { + stateOptions = [normalState, hoverState, pressedState][curState]; + stateStyle = [normalStyle, hoverStyle, pressedStyle][curState]; + label.attr(stateOptions) + .css(stateStyle); + } + }); + + label.setState = function (state) { + label.state = curState = state; + if (!state) { + label.attr(normalState) + .css(normalStyle); + } else if (state === 2) { + label.attr(pressedState) + .css(pressedStyle); + } else if (state === 3) { + label.attr(disabledState) + .css(disabledStyle); + } + }; + + return label + .on('click', function () { + if (curState !== 3) { + callback.call(label); + } + }) + .attr(normalState) + .css(extend({ cursor: 'default' }, normalStyle)); + }, + + /** + * Make a straight line crisper by not spilling out to neighbour pixels + * @param {Array} points + * @param {Number} width + */ + crispLine: function (points, width) { + // points format: [M, 0, 0, L, 100, 0] + // normalize to a crisp line + if (points[1] === points[4]) { + // Substract due to #1129. Now bottom and left axis gridlines behave the same. + points[1] = points[4] = mathRound(points[1]) - (width % 2 / 2); + } + if (points[2] === points[5]) { + points[2] = points[5] = mathRound(points[2]) + (width % 2 / 2); + } + return points; + }, + + + /** + * Draw a path + * @param {Array} path An SVG path in array form + */ + path: function (path) { + var attr = { + fill: NONE + }; + if (isArray(path)) { + attr.d = path; + } else if (isObject(path)) { // attributes + extend(attr, path); + } + return this.createElement('path').attr(attr); + }, + + /** + * Draw and return an SVG circle + * @param {Number} x The x position + * @param {Number} y The y position + * @param {Number} r The radius + */ + circle: function (x, y, r) { + var attr = isObject(x) ? + x : + { + x: x, + y: y, + r: r + }, + wrapper = this.createElement('circle'); + + wrapper.xSetter = function (value) { + this.element.setAttribute('cx', value); + }; + wrapper.ySetter = function (value) { + this.element.setAttribute('cy', value); + }; + return wrapper.attr(attr); + }, + + /** + * Draw and return an arc + * @param {Number} x X position + * @param {Number} y Y position + * @param {Number} r Radius + * @param {Number} innerR Inner radius like used in donut charts + * @param {Number} start Starting angle + * @param {Number} end Ending angle + */ + arc: function (x, y, r, innerR, start, end) { + var arc; + + if (isObject(x)) { + y = x.y; + r = x.r; + innerR = x.innerR; + start = x.start; + end = x.end; + x = x.x; + } + + // Arcs are defined as symbols for the ability to set + // attributes in attr and animate + arc = this.symbol('arc', x || 0, y || 0, r || 0, r || 0, { + innerR: innerR || 0, + start: start || 0, + end: end || 0 + }); + arc.r = r; // #959 + return arc; + }, + + /** + * Draw and return a rectangle + * @param {Number} x Left position + * @param {Number} y Top position + * @param {Number} width + * @param {Number} height + * @param {Number} r Border corner radius + * @param {Number} strokeWidth A stroke width can be supplied to allow crisp drawing + */ + rect: function (x, y, width, height, r, strokeWidth) { + + r = isObject(x) ? x.r : r; + + var wrapper = this.createElement('rect'), + attribs = isObject(x) ? x : x === UNDEFINED ? {} : { + x: x, + y: y, + width: mathMax(width, 0), + height: mathMax(height, 0) + }; + + if (strokeWidth !== UNDEFINED) { + attribs.strokeWidth = strokeWidth; + attribs = wrapper.crisp(attribs); + } + + if (r) { + attribs.r = r; + } + + wrapper.rSetter = function (value) { + attr(this.element, { + rx: value, + ry: value + }); + }; + + return wrapper.attr(attribs); + }, + + /** + * Resize the box and re-align all aligned elements + * @param {Object} width + * @param {Object} height + * @param {Boolean} animate + * + */ + setSize: function (width, height, animate) { + var renderer = this, + alignedObjects = renderer.alignedObjects, + i = alignedObjects.length; + + renderer.width = width; + renderer.height = height; + + renderer.boxWrapper[pick(animate, true) ? 'animate' : 'attr']({ + width: width, + height: height + }); + + while (i--) { + alignedObjects[i].align(); + } + }, + + /** + * Create a group + * @param {String} name The group will be given a class name of 'highcharts-{name}'. + * This can be used for styling and scripting. + */ + g: function (name) { + var elem = this.createElement('g'); + return defined(name) ? elem.attr({ 'class': PREFIX + name }) : elem; + }, + + /** + * Display an image + * @param {String} src + * @param {Number} x + * @param {Number} y + * @param {Number} width + * @param {Number} height + */ + image: function (src, x, y, width, height) { + var attribs = { + preserveAspectRatio: NONE + }, + elemWrapper; + + // optional properties + if (arguments.length > 1) { + extend(attribs, { + x: x, + y: y, + width: width, + height: height + }); + } + + elemWrapper = this.createElement('image').attr(attribs); + + // set the href in the xlink namespace + if (elemWrapper.element.setAttributeNS) { + elemWrapper.element.setAttributeNS('http://www.w3.org/1999/xlink', + 'href', src); + } else { + // could be exporting in IE + // using href throws "not supported" in ie7 and under, requries regex shim to fix later + elemWrapper.element.setAttribute('hc-svg-href', src); + } + return elemWrapper; + }, + + /** + * Draw a symbol out of pre-defined shape paths from the namespace 'symbol' object. + * + * @param {Object} symbol + * @param {Object} x + * @param {Object} y + * @param {Object} radius + * @param {Object} options + */ + symbol: function (symbol, x, y, width, height, options) { + + var obj, + + // get the symbol definition function + symbolFn = this.symbols[symbol], + + // check if there's a path defined for this symbol + path = symbolFn && symbolFn( + mathRound(x), + mathRound(y), + width, + height, + options + ), + + imageElement, + imageRegex = /^url\((.*?)\)$/, + imageSrc, + imageSize, + centerImage; + + if (path) { + + obj = this.path(path); + // expando properties for use in animate and attr + extend(obj, { + symbolName: symbol, + x: x, + y: y, + width: width, + height: height + }); + if (options) { + extend(obj, options); + } + + + // image symbols + } else if (imageRegex.test(symbol)) { + + // On image load, set the size and position + centerImage = function (img, size) { + if (img.element) { // it may be destroyed in the meantime (#1390) + img.attr({ + width: size[0], + height: size[1] + }); + + if (!img.alignByTranslate) { // #185 + img.translate( + mathRound((width - size[0]) / 2), // #1378 + mathRound((height - size[1]) / 2) + ); + } + } + }; + + imageSrc = symbol.match(imageRegex)[1]; + imageSize = symbolSizes[imageSrc]; + + // Ireate the image synchronously, add attribs async + obj = this.image(imageSrc) + .attr({ + x: x, + y: y + }); + obj.isImg = true; + + if (imageSize) { + centerImage(obj, imageSize); + } else { + // Initialize image to be 0 size so export will still function if there's no cached sizes. + // + obj.attr({ width: 0, height: 0 }); + + // Create a dummy JavaScript image to get the width and height. Due to a bug in IE < 8, + // the created element must be assigned to a variable in order to load (#292). + imageElement = createElement('img', { + onload: function () { + centerImage(obj, symbolSizes[imageSrc] = [this.width, this.height]); + }, + src: imageSrc + }); + } + } + + return obj; + }, + + /** + * An extendable collection of functions for defining symbol paths. + */ + symbols: { + 'circle': function (x, y, w, h) { + var cpw = 0.166 * w; + return [ + M, x + w / 2, y, + 'C', x + w + cpw, y, x + w + cpw, y + h, x + w / 2, y + h, + 'C', x - cpw, y + h, x - cpw, y, x + w / 2, y, + 'Z' + ]; + }, + + 'square': function (x, y, w, h) { + return [ + M, x, y, + L, x + w, y, + x + w, y + h, + x, y + h, + 'Z' + ]; + }, + + 'triangle': function (x, y, w, h) { + return [ + M, x + w / 2, y, + L, x + w, y + h, + x, y + h, + 'Z' + ]; + }, + + 'triangle-down': function (x, y, w, h) { + return [ + M, x, y, + L, x + w, y, + x + w / 2, y + h, + 'Z' + ]; + }, + 'diamond': function (x, y, w, h) { + return [ + M, x + w / 2, y, + L, x + w, y + h / 2, + x + w / 2, y + h, + x, y + h / 2, + 'Z' + ]; + }, + 'arc': function (x, y, w, h, options) { + var start = options.start, + radius = options.r || w || h, + end = options.end - 0.001, // to prevent cos and sin of start and end from becoming equal on 360 arcs (related: #1561) + innerRadius = options.innerR, + open = options.open, + cosStart = mathCos(start), + sinStart = mathSin(start), + cosEnd = mathCos(end), + sinEnd = mathSin(end), + longArc = options.end - start < mathPI ? 0 : 1; + + return [ + M, + x + radius * cosStart, + y + radius * sinStart, + 'A', // arcTo + radius, // x radius + radius, // y radius + 0, // slanting + longArc, // long or short arc + 1, // clockwise + x + radius * cosEnd, + y + radius * sinEnd, + open ? M : L, + x + innerRadius * cosEnd, + y + innerRadius * sinEnd, + 'A', // arcTo + innerRadius, // x radius + innerRadius, // y radius + 0, // slanting + longArc, // long or short arc + 0, // clockwise + x + innerRadius * cosStart, + y + innerRadius * sinStart, + + open ? '' : 'Z' // close + ]; + }, + + /** + * Callout shape used for default tooltips, also used for rounded rectangles in VML + */ + callout: function (x, y, w, h, options) { + var arrowLength = 6, + halfDistance = 6, + r = mathMin((options && options.r) || 0, w, h), + safeDistance = r + halfDistance, + anchorX = options && options.anchorX, + anchorY = options && options.anchorY, + path, + normalizer = mathRound(options.strokeWidth || 0) % 2 / 2; // mathRound because strokeWidth can sometimes have roundoff errors; + + x += normalizer; + y += normalizer; + path = [ + 'M', x + r, y, + 'L', x + w - r, y, // top side + 'C', x + w, y, x + w, y, x + w, y + r, // top-right corner + 'L', x + w, y + h - r, // right side + 'C', x + w, y + h, x + w, y + h, x + w - r, y + h, // bottom-right corner + 'L', x + r, y + h, // bottom side + 'C', x, y + h, x, y + h, x, y + h - r, // bottom-left corner + 'L', x, y + r, // left side + 'C', x, y, x, y, x + r, y // top-right corner + ]; + + if (anchorX && anchorX > w && anchorY > y + safeDistance && anchorY < y + h - safeDistance) { // replace right side + path.splice(13, 3, + 'L', x + w, anchorY - halfDistance, + x + w + arrowLength, anchorY, + x + w, anchorY + halfDistance, + x + w, y + h - r + ); + } else if (anchorX && anchorX < 0 && anchorY > y + safeDistance && anchorY < y + h - safeDistance) { // replace left side + path.splice(33, 3, + 'L', x, anchorY + halfDistance, + x - arrowLength, anchorY, + x, anchorY - halfDistance, + x, y + r + ); + } else if (anchorY && anchorY > h && anchorX > x + safeDistance && anchorX < x + w - safeDistance) { // replace bottom + path.splice(23, 3, + 'L', anchorX + halfDistance, y + h, + anchorX, y + h + arrowLength, + anchorX - halfDistance, y + h, + x + r, y + h + ); + } else if (anchorY && anchorY < 0 && anchorX > x + safeDistance && anchorX < x + w - safeDistance) { // replace top + path.splice(3, 3, + 'L', anchorX - halfDistance, y, + anchorX, y - arrowLength, + anchorX + halfDistance, y, + w - r, y + ); + } + return path; + } + }, + + /** + * Define a clipping rectangle + * @param {String} id + * @param {Number} x + * @param {Number} y + * @param {Number} width + * @param {Number} height + */ + clipRect: function (x, y, width, height) { + var wrapper, + id = PREFIX + idCounter++, + + clipPath = this.createElement('clipPath').attr({ + id: id + }).add(this.defs); + + wrapper = this.rect(x, y, width, height, 0).add(clipPath); + wrapper.id = id; + wrapper.clipPath = clipPath; + + return wrapper; + }, + + + + + + /** + * Add text to the SVG object + * @param {String} str + * @param {Number} x Left position + * @param {Number} y Top position + * @param {Boolean} useHTML Use HTML to render the text + */ + text: function (str, x, y, useHTML) { + + // declare variables + var renderer = this, + fakeSVG = useCanVG || (!hasSVG && renderer.forExport), + wrapper, + attr = {}; + + if (useHTML && !renderer.forExport) { + return renderer.html(str, x, y); + } + + attr.x = Math.round(x || 0); // X is always needed for line-wrap logic + if (y) { + attr.y = Math.round(y); + } + if (str || str === 0) { + attr.text = str; + } + + wrapper = renderer.createElement('text') + .attr(attr); + + // Prevent wrapping from creating false offsetWidths in export in legacy IE (#1079, #1063) + if (fakeSVG) { + wrapper.css({ + position: ABSOLUTE + }); + } + + if (!useHTML) { + wrapper.xSetter = function (value, key, element) { + var tspans = element.getElementsByTagName('tspan'), + tspan, + parentVal = element.getAttribute(key), + i; + for (i = 0; i < tspans.length; i++) { + tspan = tspans[i]; + // If the x values are equal, the tspan represents a linebreak + if (tspan.getAttribute(key) === parentVal) { + tspan.setAttribute(key, value); + } + } + element.setAttribute(key, value); + }; + } + + return wrapper; + }, + + /** + * Utility to return the baseline offset and total line height from the font size + */ + fontMetrics: function (fontSize, elem) { + fontSize = fontSize || this.style.fontSize; + if (elem && win.getComputedStyle) { + elem = elem.element || elem; // SVGElement + fontSize = win.getComputedStyle(elem, "").fontSize; + } + fontSize = /px/.test(fontSize) ? pInt(fontSize) : /em/.test(fontSize) ? parseFloat(fontSize) * 12 : 12; + + // Empirical values found by comparing font size and bounding box height. + // Applies to the default font family. http://jsfiddle.net/highcharts/7xvn7/ + var lineHeight = fontSize < 24 ? fontSize + 4 : mathRound(fontSize * 1.2), + baseline = mathRound(lineHeight * 0.8); + + return { + h: lineHeight, + b: baseline, + f: fontSize + }; + }, + + /** + * Add a label, a text item that can hold a colored or gradient background + * as well as a border and shadow. + * @param {string} str + * @param {Number} x + * @param {Number} y + * @param {String} shape + * @param {Number} anchorX In case the shape has a pointer, like a flag, this is the + * coordinates it should be pinned to + * @param {Number} anchorY + * @param {Boolean} baseline Whether to position the label relative to the text baseline, + * like renderer.text, or to the upper border of the rectangle. + * @param {String} className Class name for the group + */ + label: function (str, x, y, shape, anchorX, anchorY, useHTML, baseline, className) { + + var renderer = this, + wrapper = renderer.g(className), + text = renderer.text('', 0, 0, useHTML) + .attr({ + zIndex: 1 + }), + //.add(wrapper), + box, + bBox, + alignFactor = 0, + padding = 3, + paddingLeft = 0, + width, + height, + wrapperX, + wrapperY, + crispAdjust = 0, + deferredAttr = {}, + baselineOffset, + needsBox; + + /** + * This function runs after the label is added to the DOM (when the bounding box is + * available), and after the text of the label is updated to detect the new bounding + * box and reflect it in the border box. + */ + function updateBoxSize() { + var boxX, + boxY, + style = text.element.style; + + bBox = (width === undefined || height === undefined || wrapper.styles.textAlign) && text.textStr && + text.getBBox(); + wrapper.width = (width || bBox.width || 0) + 2 * padding + paddingLeft; + wrapper.height = (height || bBox.height || 0) + 2 * padding; + + // update the label-scoped y offset + baselineOffset = padding + renderer.fontMetrics(style && style.fontSize, text).b; + + + if (needsBox) { + + // create the border box if it is not already present + if (!box) { + boxX = mathRound(-alignFactor * padding); + boxY = baseline ? -baselineOffset : 0; + + wrapper.box = box = shape ? + renderer.symbol(shape, boxX, boxY, wrapper.width, wrapper.height, deferredAttr) : + renderer.rect(boxX, boxY, wrapper.width, wrapper.height, 0, deferredAttr[STROKE_WIDTH]); + box.attr('fill', NONE).add(wrapper); + } + + // apply the box attributes + if (!box.isImg) { // #1630 + box.attr(extend({ + width: mathRound(wrapper.width), + height: mathRound(wrapper.height) + }, deferredAttr)); + } + deferredAttr = null; + } + } + + /** + * This function runs after setting text or padding, but only if padding is changed + */ + function updateTextPadding() { + var styles = wrapper.styles, + textAlign = styles && styles.textAlign, + x = paddingLeft + padding * (1 - alignFactor), + y; + + // determin y based on the baseline + y = baseline ? 0 : baselineOffset; + + // compensate for alignment + if (defined(width) && bBox && (textAlign === 'center' || textAlign === 'right')) { + x += { center: 0.5, right: 1 }[textAlign] * (width - bBox.width); + } + + // update if anything changed + if (x !== text.x || y !== text.y) { + text.attr('x', x); + if (y !== UNDEFINED) { + text.attr('y', y); + } + } + + // record current values + text.x = x; + text.y = y; + } + + /** + * Set a box attribute, or defer it if the box is not yet created + * @param {Object} key + * @param {Object} value + */ + function boxAttr(key, value) { + if (box) { + box.attr(key, value); + } else { + deferredAttr[key] = value; + } + } + + /** + * After the text element is added, get the desired size of the border box + * and add it before the text in the DOM. + */ + wrapper.onAdd = function () { + text.add(wrapper); + wrapper.attr({ + text: str || '', // alignment is available now + x: x, + y: y + }); + + if (box && defined(anchorX)) { + wrapper.attr({ + anchorX: anchorX, + anchorY: anchorY + }); + } + }; + + /* + * Add specific attribute setters. + */ + + // only change local variables + wrapper.widthSetter = function (value) { + width = value; + }; + wrapper.heightSetter = function (value) { + height = value; + }; + wrapper.paddingSetter = function (value) { + if (defined(value) && value !== padding) { + padding = value; + updateTextPadding(); + } + }; + wrapper.paddingLeftSetter = function (value) { + if (defined(value) && value !== paddingLeft) { + paddingLeft = value; + updateTextPadding(); + } + }; + + + // change local variable and prevent setting attribute on the group + wrapper.alignSetter = function (value) { + alignFactor = { left: 0, center: 0.5, right: 1 }[value]; + }; + + // apply these to the box and the text alike + wrapper.textSetter = function (value) { + if (value !== UNDEFINED) { + text.textSetter(value); + } + updateBoxSize(); + updateTextPadding(); + }; + + // apply these to the box but not to the text + wrapper['stroke-widthSetter'] = function (value, key) { + if (value) { + needsBox = true; + } + crispAdjust = value % 2 / 2; + boxAttr(key, value); + }; + wrapper.strokeSetter = wrapper.fillSetter = wrapper.rSetter = function (value, key) { + if (key === 'fill' && value) { + needsBox = true; + } + boxAttr(key, value); + }; + wrapper.anchorXSetter = function (value, key) { + anchorX = value; + boxAttr(key, value + crispAdjust - wrapperX); + }; + wrapper.anchorYSetter = function (value, key) { + anchorY = value; + boxAttr(key, value - wrapperY); + }; + + // rename attributes + wrapper.xSetter = function (value) { + wrapper.x = value; // for animation getter + if (alignFactor) { + value -= alignFactor * ((width || bBox.width) + padding); + } + wrapperX = mathRound(value); + wrapper.attr('translateX', wrapperX); + }; + wrapper.ySetter = function (value) { + wrapperY = wrapper.y = mathRound(value); + wrapper.attr('translateY', wrapperY); + }; + + // Redirect certain methods to either the box or the text + var baseCss = wrapper.css; + return extend(wrapper, { + /** + * Pick up some properties and apply them to the text instead of the wrapper + */ + css: function (styles) { + if (styles) { + var textStyles = {}; + styles = merge(styles); // create a copy to avoid altering the original object (#537) + each(wrapper.textProps, function (prop) { + if (styles[prop] !== UNDEFINED) { + textStyles[prop] = styles[prop]; + delete styles[prop]; + } + }); + text.css(textStyles); + } + return baseCss.call(wrapper, styles); + }, + /** + * Return the bounding box of the box, not the group + */ + getBBox: function () { + return { + width: bBox.width + 2 * padding, + height: bBox.height + 2 * padding, + x: bBox.x - padding, + y: bBox.y - padding + }; + }, + /** + * Apply the shadow to the box + */ + shadow: function (b) { + if (box) { + box.shadow(b); + } + return wrapper; + }, + /** + * Destroy and release memory. + */ + destroy: function () { + + // Added by button implementation + removeEvent(wrapper.element, 'mouseenter'); + removeEvent(wrapper.element, 'mouseleave'); + + if (text) { + text = text.destroy(); + } + if (box) { + box = box.destroy(); + } + // Call base implementation to destroy the rest + SVGElement.prototype.destroy.call(wrapper); + + // Release local pointers (#1298) + wrapper = renderer = updateBoxSize = updateTextPadding = boxAttr = null; + } + }); + } +}; // end SVGRenderer + + +// general renderer +Renderer = SVGRenderer; +// extend SvgElement for useHTML option +extend(SVGElement.prototype, { + /** + * Apply CSS to HTML elements. This is used in text within SVG rendering and + * by the VML renderer + */ + htmlCss: function (styles) { + var wrapper = this, + element = wrapper.element, + textWidth = styles && element.tagName === 'SPAN' && styles.width; + + if (textWidth) { + delete styles.width; + wrapper.textWidth = textWidth; + wrapper.updateTransform(); + } + + wrapper.styles = extend(wrapper.styles, styles); + css(wrapper.element, styles); + + return wrapper; + }, + + /** + * VML and useHTML method for calculating the bounding box based on offsets + * @param {Boolean} refresh Whether to force a fresh value from the DOM or to + * use the cached value + * + * @return {Object} A hash containing values for x, y, width and height + */ + + htmlGetBBox: function () { + var wrapper = this, + element = wrapper.element, + bBox = wrapper.bBox; + + // faking getBBox in exported SVG in legacy IE + if (!bBox) { + // faking getBBox in exported SVG in legacy IE (is this a duplicate of the fix for #1079?) + if (element.nodeName === 'text') { + element.style.position = ABSOLUTE; + } + + bBox = wrapper.bBox = { + x: element.offsetLeft, + y: element.offsetTop, + width: element.offsetWidth, + height: element.offsetHeight + }; + } + + return bBox; + }, + + /** + * VML override private method to update elements based on internal + * properties based on SVG transform + */ + htmlUpdateTransform: function () { + // aligning non added elements is expensive + if (!this.added) { + this.alignOnAdd = true; + return; + } + + var wrapper = this, + renderer = wrapper.renderer, + elem = wrapper.element, + translateX = wrapper.translateX || 0, + translateY = wrapper.translateY || 0, + x = wrapper.x || 0, + y = wrapper.y || 0, + align = wrapper.textAlign || 'left', + alignCorrection = { left: 0, center: 0.5, right: 1 }[align], + shadows = wrapper.shadows; + + // apply translate + css(elem, { + marginLeft: translateX, + marginTop: translateY + }); + if (shadows) { // used in labels/tooltip + each(shadows, function (shadow) { + css(shadow, { + marginLeft: translateX + 1, + marginTop: translateY + 1 + }); + }); + } + + // apply inversion + if (wrapper.inverted) { // wrapper is a group + each(elem.childNodes, function (child) { + renderer.invertChild(child, elem); + }); + } + + if (elem.tagName === 'SPAN') { + + var width, + rotation = wrapper.rotation, + baseline, + textWidth = pInt(wrapper.textWidth), + currentTextTransform = [rotation, align, elem.innerHTML, wrapper.textWidth].join(','); + + if (currentTextTransform !== wrapper.cTT) { // do the calculations and DOM access only if properties changed + + + baseline = renderer.fontMetrics(elem.style.fontSize).b; + + // Renderer specific handling of span rotation + if (defined(rotation)) { + wrapper.setSpanRotation(rotation, alignCorrection, baseline); + } + + width = pick(wrapper.elemWidth, elem.offsetWidth); + + // Update textWidth + if (width > textWidth && /[ \-]/.test(elem.textContent || elem.innerText)) { // #983, #1254 + css(elem, { + width: textWidth + PX, + display: 'block', + whiteSpace: 'normal' + }); + width = textWidth; + } + + wrapper.getSpanCorrection(width, baseline, alignCorrection, rotation, align); + } + + // apply position with correction + css(elem, { + left: (x + (wrapper.xCorr || 0)) + PX, + top: (y + (wrapper.yCorr || 0)) + PX + }); + + // force reflow in webkit to apply the left and top on useHTML element (#1249) + if (isWebKit) { + baseline = elem.offsetHeight; // assigned to baseline for JSLint purpose + } + + // record current text transform + wrapper.cTT = currentTextTransform; + } + }, + + /** + * Set the rotation of an individual HTML span + */ + setSpanRotation: function (rotation, alignCorrection, baseline) { + var rotationStyle = {}, + cssTransformKey = isIE ? '-ms-transform' : isWebKit ? '-webkit-transform' : isFirefox ? 'MozTransform' : isOpera ? '-o-transform' : ''; + + rotationStyle[cssTransformKey] = rotationStyle.transform = 'rotate(' + rotation + 'deg)'; + rotationStyle[cssTransformKey + (isFirefox ? 'Origin' : '-origin')] = rotationStyle.transformOrigin = (alignCorrection * 100) + '% ' + baseline + 'px'; + css(this.element, rotationStyle); + }, + + /** + * Get the correction in X and Y positioning as the element is rotated. + */ + getSpanCorrection: function (width, baseline, alignCorrection) { + this.xCorr = -width * alignCorrection; + this.yCorr = -baseline; + } +}); + +// Extend SvgRenderer for useHTML option. +extend(SVGRenderer.prototype, { + /** + * Create HTML text node. This is used by the VML renderer as well as the SVG + * renderer through the useHTML option. + * + * @param {String} str + * @param {Number} x + * @param {Number} y + */ + html: function (str, x, y) { + var wrapper = this.createElement('span'), + element = wrapper.element, + renderer = wrapper.renderer; + + // Text setter + wrapper.textSetter = function (value) { + if (value !== element.innerHTML) { + delete this.bBox; + } + element.innerHTML = this.textStr = value; + }; + + // Various setters which rely on update transform + wrapper.xSetter = wrapper.ySetter = wrapper.alignSetter = wrapper.rotationSetter = function (value, key) { + if (key === 'align') { + key = 'textAlign'; // Do not overwrite the SVGElement.align method. Same as VML. + } + wrapper[key] = value; + wrapper.htmlUpdateTransform(); + }; + + // Set the default attributes + wrapper.attr({ + text: str, + x: mathRound(x), + y: mathRound(y) + }) + .css({ + position: ABSOLUTE, + whiteSpace: 'nowrap', + fontFamily: this.style.fontFamily, + fontSize: this.style.fontSize + }); + + // Use the HTML specific .css method + wrapper.css = wrapper.htmlCss; + + // This is specific for HTML within SVG + if (renderer.isSVG) { + wrapper.add = function (svgGroupWrapper) { + + var htmlGroup, + container = renderer.box.parentNode, + parentGroup, + parents = []; + + this.parentGroup = svgGroupWrapper; + + // Create a mock group to hold the HTML elements + if (svgGroupWrapper) { + htmlGroup = svgGroupWrapper.div; + if (!htmlGroup) { + + // Read the parent chain into an array and read from top down + parentGroup = svgGroupWrapper; + while (parentGroup) { + + parents.push(parentGroup); + + // Move up to the next parent group + parentGroup = parentGroup.parentGroup; + } + + // Ensure dynamically updating position when any parent is translated + each(parents.reverse(), function (parentGroup) { + var htmlGroupStyle; + + // Create a HTML div and append it to the parent div to emulate + // the SVG group structure + htmlGroup = parentGroup.div = parentGroup.div || createElement(DIV, { + className: attr(parentGroup.element, 'class') + }, { + position: ABSOLUTE, + left: (parentGroup.translateX || 0) + PX, + top: (parentGroup.translateY || 0) + PX + }, htmlGroup || container); // the top group is appended to container + + // Shortcut + htmlGroupStyle = htmlGroup.style; + + // Set listeners to update the HTML div's position whenever the SVG group + // position is changed + extend(parentGroup, { + translateXSetter: function (value, key) { + htmlGroupStyle.left = value + PX; + parentGroup[key] = value; + parentGroup.doTransform = true; + }, + translateYSetter: function (value, key) { + htmlGroupStyle.top = value + PX; + parentGroup[key] = value; + parentGroup.doTransform = true; + }, + visibilitySetter: function (value, key) { + htmlGroupStyle[key] = value; + } + }); + }); + + } + } else { + htmlGroup = container; + } + + htmlGroup.appendChild(element); + + // Shared with VML: + wrapper.added = true; + if (wrapper.alignOnAdd) { + wrapper.htmlUpdateTransform(); + } + + return wrapper; + }; + } + return wrapper; + } +}); + +/* **************************************************************************** + * * + * START OF INTERNET EXPLORER <= 8 SPECIFIC CODE * + * * + * For applications and websites that don't need IE support, like platform * + * targeted mobile apps and web apps, this code can be removed. * + * * + *****************************************************************************/ + +/** + * @constructor + */ +var VMLRenderer, VMLElement; +if (!hasSVG && !useCanVG) { + +/** + * The VML element wrapper. + */ +VMLElement = { + + /** + * Initialize a new VML element wrapper. It builds the markup as a string + * to minimize DOM traffic. + * @param {Object} renderer + * @param {Object} nodeName + */ + init: function (renderer, nodeName) { + var wrapper = this, + markup = ['<', nodeName, ' filled="f" stroked="f"'], + style = ['position: ', ABSOLUTE, ';'], + isDiv = nodeName === DIV; + + // divs and shapes need size + if (nodeName === 'shape' || isDiv) { + style.push('left:0;top:0;width:1px;height:1px;'); + } + style.push('visibility: ', isDiv ? HIDDEN : VISIBLE); + + markup.push(' style="', style.join(''), '"/>'); + + // create element with default attributes and style + if (nodeName) { + markup = isDiv || nodeName === 'span' || nodeName === 'img' ? + markup.join('') + : renderer.prepVML(markup); + wrapper.element = createElement(markup); + } + + wrapper.renderer = renderer; + }, + + /** + * Add the node to the given parent + * @param {Object} parent + */ + add: function (parent) { + var wrapper = this, + renderer = wrapper.renderer, + element = wrapper.element, + box = renderer.box, + inverted = parent && parent.inverted, + + // get the parent node + parentNode = parent ? + parent.element || parent : + box; + + + // if the parent group is inverted, apply inversion on all children + if (inverted) { // only on groups + renderer.invertChild(element, parentNode); + } + + // append it + parentNode.appendChild(element); + + // align text after adding to be able to read offset + wrapper.added = true; + if (wrapper.alignOnAdd && !wrapper.deferUpdateTransform) { + wrapper.updateTransform(); + } + + // fire an event for internal hooks + if (wrapper.onAdd) { + wrapper.onAdd(); + } + + return wrapper; + }, + + /** + * VML always uses htmlUpdateTransform + */ + updateTransform: SVGElement.prototype.htmlUpdateTransform, + + /** + * Set the rotation of a span with oldIE's filter + */ + setSpanRotation: function () { + // Adjust for alignment and rotation. Rotation of useHTML content is not yet implemented + // but it can probably be implemented for Firefox 3.5+ on user request. FF3.5+ + // has support for CSS3 transform. The getBBox method also needs to be updated + // to compensate for the rotation, like it currently does for SVG. + // Test case: http://jsfiddle.net/highcharts/Ybt44/ + + var rotation = this.rotation, + costheta = mathCos(rotation * deg2rad), + sintheta = mathSin(rotation * deg2rad); + + css(this.element, { + filter: rotation ? ['progid:DXImageTransform.Microsoft.Matrix(M11=', costheta, + ', M12=', -sintheta, ', M21=', sintheta, ', M22=', costheta, + ', sizingMethod=\'auto expand\')'].join('') : NONE + }); + }, + + /** + * Get the positioning correction for the span after rotating. + */ + getSpanCorrection: function (width, baseline, alignCorrection, rotation, align) { + + var costheta = rotation ? mathCos(rotation * deg2rad) : 1, + sintheta = rotation ? mathSin(rotation * deg2rad) : 0, + height = pick(this.elemHeight, this.element.offsetHeight), + quad, + nonLeft = align && align !== 'left'; + + // correct x and y + this.xCorr = costheta < 0 && -width; + this.yCorr = sintheta < 0 && -height; + + // correct for baseline and corners spilling out after rotation + quad = costheta * sintheta < 0; + this.xCorr += sintheta * baseline * (quad ? 1 - alignCorrection : alignCorrection); + this.yCorr -= costheta * baseline * (rotation ? (quad ? alignCorrection : 1 - alignCorrection) : 1); + // correct for the length/height of the text + if (nonLeft) { + this.xCorr -= width * alignCorrection * (costheta < 0 ? -1 : 1); + if (rotation) { + this.yCorr -= height * alignCorrection * (sintheta < 0 ? -1 : 1); + } + css(this.element, { + textAlign: align + }); + } + }, + + /** + * Converts a subset of an SVG path definition to its VML counterpart. Takes an array + * as the parameter and returns a string. + */ + pathToVML: function (value) { + // convert paths + var i = value.length, + path = []; + + while (i--) { + + // Multiply by 10 to allow subpixel precision. + // Substracting half a pixel seems to make the coordinates + // align with SVG, but this hasn't been tested thoroughly + if (isNumber(value[i])) { + path[i] = mathRound(value[i] * 10) - 5; + } else if (value[i] === 'Z') { // close the path + path[i] = 'x'; + } else { + path[i] = value[i]; + + // When the start X and end X coordinates of an arc are too close, + // they are rounded to the same value above. In this case, substract or + // add 1 from the end X and Y positions. #186, #760, #1371, #1410. + if (value.isArc && (value[i] === 'wa' || value[i] === 'at')) { + // Start and end X + if (path[i + 5] === path[i + 7]) { + path[i + 7] += value[i + 7] > value[i + 5] ? 1 : -1; + } + // Start and end Y + if (path[i + 6] === path[i + 8]) { + path[i + 8] += value[i + 8] > value[i + 6] ? 1 : -1; + } + } + } + } + + + // Loop up again to handle path shortcuts (#2132) + /*while (i++ < path.length) { + if (path[i] === 'H') { // horizontal line to + path[i] = 'L'; + path.splice(i + 2, 0, path[i - 1]); + } else if (path[i] === 'V') { // vertical line to + path[i] = 'L'; + path.splice(i + 1, 0, path[i - 2]); + } + }*/ + return path.join(' ') || 'x'; + }, + + /** + * Set the element's clipping to a predefined rectangle + * + * @param {String} id The id of the clip rectangle + */ + clip: function (clipRect) { + var wrapper = this, + clipMembers, + cssRet; + + if (clipRect) { + clipMembers = clipRect.members; + erase(clipMembers, wrapper); // Ensure unique list of elements (#1258) + clipMembers.push(wrapper); + wrapper.destroyClip = function () { + erase(clipMembers, wrapper); + }; + cssRet = clipRect.getCSS(wrapper); + + } else { + if (wrapper.destroyClip) { + wrapper.destroyClip(); + } + cssRet = { clip: docMode8 ? 'inherit' : 'rect(auto)' }; // #1214 + } + + return wrapper.css(cssRet); + + }, + + /** + * Set styles for the element + * @param {Object} styles + */ + css: SVGElement.prototype.htmlCss, + + /** + * Removes a child either by removeChild or move to garbageBin. + * Issue 490; in VML removeChild results in Orphaned nodes according to sIEve, discardElement does not. + */ + safeRemoveChild: function (element) { + // discardElement will detach the node from its parent before attaching it + // to the garbage bin. Therefore it is important that the node is attached and have parent. + if (element.parentNode) { + discardElement(element); + } + }, + + /** + * Extend element.destroy by removing it from the clip members array + */ + destroy: function () { + if (this.destroyClip) { + this.destroyClip(); + } + + return SVGElement.prototype.destroy.apply(this); + }, + + /** + * Add an event listener. VML override for normalizing event parameters. + * @param {String} eventType + * @param {Function} handler + */ + on: function (eventType, handler) { + // simplest possible event model for internal use + this.element['on' + eventType] = function () { + var evt = win.event; + evt.target = evt.srcElement; + handler(evt); + }; + return this; + }, + + /** + * In stacked columns, cut off the shadows so that they don't overlap + */ + cutOffPath: function (path, length) { + + var len; + + path = path.split(/[ ,]/); + len = path.length; + + if (len === 9 || len === 11) { + path[len - 4] = path[len - 2] = pInt(path[len - 2]) - 10 * length; + } + return path.join(' '); + }, + + /** + * Apply a drop shadow by copying elements and giving them different strokes + * @param {Boolean|Object} shadowOptions + */ + shadow: function (shadowOptions, group, cutOff) { + var shadows = [], + i, + element = this.element, + renderer = this.renderer, + shadow, + elemStyle = element.style, + markup, + path = element.path, + strokeWidth, + modifiedPath, + shadowWidth, + shadowElementOpacity; + + // some times empty paths are not strings + if (path && typeof path.value !== 'string') { + path = 'x'; + } + modifiedPath = path; + + if (shadowOptions) { + shadowWidth = pick(shadowOptions.width, 3); + shadowElementOpacity = (shadowOptions.opacity || 0.15) / shadowWidth; + for (i = 1; i <= 3; i++) { + + strokeWidth = (shadowWidth * 2) + 1 - (2 * i); + + // Cut off shadows for stacked column items + if (cutOff) { + modifiedPath = this.cutOffPath(path.value, strokeWidth + 0.5); + } + + markup = ['']; + + shadow = createElement(renderer.prepVML(markup), + null, { + left: pInt(elemStyle.left) + pick(shadowOptions.offsetX, 1), + top: pInt(elemStyle.top) + pick(shadowOptions.offsetY, 1) + } + ); + if (cutOff) { + shadow.cutOff = strokeWidth + 1; + } + + // apply the opacity + markup = ['']; + createElement(renderer.prepVML(markup), null, null, shadow); + + + // insert it + if (group) { + group.element.appendChild(shadow); + } else { + element.parentNode.insertBefore(shadow, element); + } + + // record it + shadows.push(shadow); + + } + + this.shadows = shadows; + } + return this; + }, + updateShadows: noop, // Used in SVG only + + setAttr: function (key, value) { + if (docMode8) { // IE8 setAttribute bug + this.element[key] = value; + } else { + this.element.setAttribute(key, value); + } + }, + classSetter: function (value) { + // IE8 Standards mode has problems retrieving the className unless set like this + this.element.className = value; + }, + dashstyleSetter: function (value, key, element) { + var strokeElem = element.getElementsByTagName('stroke')[0] || + createElement(this.renderer.prepVML(['']), null, null, element); + strokeElem[key] = value || 'solid'; + this[key] = value; /* because changing stroke-width will change the dash length + and cause an epileptic effect */ + }, + dSetter: function (value, key, element) { + var i, + shadows = this.shadows; + value = value || []; + this.d = value.join && value.join(' '); // used in getter for animation + + element.path = value = this.pathToVML(value); + + // update shadows + if (shadows) { + i = shadows.length; + while (i--) { + shadows[i].path = shadows[i].cutOff ? this.cutOffPath(value, shadows[i].cutOff) : value; + } + } + this.setAttr(key, value); + }, + fillSetter: function (value, key, element) { + var nodeName = element.nodeName; + if (nodeName === 'SPAN') { // text color + element.style.color = value; + } else if (nodeName !== 'IMG') { // #1336 + element.filled = value !== NONE; + this.setAttr('fillcolor', this.renderer.color(value, element, key, this)); + } + }, + opacitySetter: noop, // Don't bother - animation is too slow and filters introduce artifacts + rotationSetter: function (value, key, element) { + var style = element.style; + this[key] = style[key] = value; // style is for #1873 + + // Correction for the 1x1 size of the shape container. Used in gauge needles. + style.left = -mathRound(mathSin(value * deg2rad) + 1) + PX; + style.top = mathRound(mathCos(value * deg2rad)) + PX; + }, + strokeSetter: function (value, key, element) { + this.setAttr('strokecolor', this.renderer.color(value, element, key)); + }, + 'stroke-widthSetter': function (value, key, element) { + element.stroked = !!value; // VML "stroked" attribute + this[key] = value; // used in getter, issue #113 + if (isNumber(value)) { + value += PX; + } + this.setAttr('strokeweight', value); + }, + titleSetter: function (value, key) { + this.setAttr(key, value); + }, + visibilitySetter: function (value, key, element) { + + // Handle inherited visibility + if (value === 'inherit') { + value = VISIBLE; + } + + // Let the shadow follow the main element + if (this.shadows) { + each(this.shadows, function (shadow) { + shadow.style[key] = value; + }); + } + + // Instead of toggling the visibility CSS property, move the div out of the viewport. + // This works around #61 and #586 + if (element.nodeName === 'DIV') { + value = value === HIDDEN ? '-999em' : 0; + + // In order to redraw, IE7 needs the div to be visible when tucked away + // outside the viewport. So the visibility is actually opposite of + // the expected value. This applies to the tooltip only. + if (!docMode8) { + element.style[key] = value ? VISIBLE : HIDDEN; + } + key = 'top'; + } + element.style[key] = value; + }, + xSetter: function (value, key, element) { + this[key] = value; // used in getter + + if (key === 'x') { + key = 'left'; + } else if (key === 'y') { + key = 'top'; + }/* else { + value = mathMax(0, value); // don't set width or height below zero (#311) + }*/ + + // clipping rectangle special + if (this.updateClipping) { + this[key] = value; // the key is now 'left' or 'top' for 'x' and 'y' + this.updateClipping(); + } else { + // normal + element.style[key] = value; + } + }, + zIndexSetter: function (value, key, element) { + element.style[key] = value; + } +}; +Highcharts.VMLElement = VMLElement = extendClass(SVGElement, VMLElement); + +// Some shared setters +VMLElement.prototype.ySetter = + VMLElement.prototype.widthSetter = + VMLElement.prototype.heightSetter = + VMLElement.prototype.xSetter; + + +/** + * The VML renderer + */ +var VMLRendererExtension = { // inherit SVGRenderer + + Element: VMLElement, + isIE8: userAgent.indexOf('MSIE 8.0') > -1, + + + /** + * Initialize the VMLRenderer + * @param {Object} container + * @param {Number} width + * @param {Number} height + */ + init: function (container, width, height, style) { + var renderer = this, + boxWrapper, + box, + css; + + renderer.alignedObjects = []; + + boxWrapper = renderer.createElement(DIV) + .css(extend(this.getStyle(style), { position: RELATIVE})); + box = boxWrapper.element; + container.appendChild(boxWrapper.element); + + + // generate the containing box + renderer.isVML = true; + renderer.box = box; + renderer.boxWrapper = boxWrapper; + renderer.cache = {}; + + + renderer.setSize(width, height, false); + + // The only way to make IE6 and IE7 print is to use a global namespace. However, + // with IE8 the only way to make the dynamic shapes visible in screen and print mode + // seems to be to add the xmlns attribute and the behaviour style inline. + if (!doc.namespaces.hcv) { + + doc.namespaces.add('hcv', 'urn:schemas-microsoft-com:vml'); + + // Setup default CSS (#2153, #2368, #2384) + css = 'hcv\\:fill, hcv\\:path, hcv\\:shape, hcv\\:stroke' + + '{ behavior:url(#default#VML); display: inline-block; } '; + try { + doc.createStyleSheet().cssText = css; + } catch (e) { + doc.styleSheets[0].cssText += css; + } + + } + }, + + + /** + * Detect whether the renderer is hidden. This happens when one of the parent elements + * has display: none + */ + isHidden: function () { + return !this.box.offsetWidth; + }, + + /** + * Define a clipping rectangle. In VML it is accomplished by storing the values + * for setting the CSS style to all associated members. + * + * @param {Number} x + * @param {Number} y + * @param {Number} width + * @param {Number} height + */ + clipRect: function (x, y, width, height) { + + // create a dummy element + var clipRect = this.createElement(), + isObj = isObject(x); + + // mimic a rectangle with its style object for automatic updating in attr + return extend(clipRect, { + members: [], + left: (isObj ? x.x : x) + 1, + top: (isObj ? x.y : y) + 1, + width: (isObj ? x.width : width) - 1, + height: (isObj ? x.height : height) - 1, + getCSS: function (wrapper) { + var element = wrapper.element, + nodeName = element.nodeName, + isShape = nodeName === 'shape', + inverted = wrapper.inverted, + rect = this, + top = rect.top - (isShape ? element.offsetTop : 0), + left = rect.left, + right = left + rect.width, + bottom = top + rect.height, + ret = { + clip: 'rect(' + + mathRound(inverted ? left : top) + 'px,' + + mathRound(inverted ? bottom : right) + 'px,' + + mathRound(inverted ? right : bottom) + 'px,' + + mathRound(inverted ? top : left) + 'px)' + }; + + // issue 74 workaround + if (!inverted && docMode8 && nodeName === 'DIV') { + extend(ret, { + width: right + PX, + height: bottom + PX + }); + } + return ret; + }, + + // used in attr and animation to update the clipping of all members + updateClipping: function () { + each(clipRect.members, function (member) { + if (member.element) { // Deleted series, like in stock/members/series-remove demo. Should be removed from members, but this will do. + member.css(clipRect.getCSS(member)); + } + }); + } + }); + + }, + + + /** + * Take a color and return it if it's a string, make it a gradient if it's a + * gradient configuration object, and apply opacity. + * + * @param {Object} color The color or config object + */ + color: function (color, elem, prop, wrapper) { + var renderer = this, + colorObject, + regexRgba = /^rgba/, + markup, + fillType, + ret = NONE; + + // Check for linear or radial gradient + if (color && color.linearGradient) { + fillType = 'gradient'; + } else if (color && color.radialGradient) { + fillType = 'pattern'; + } + + + if (fillType) { + + var stopColor, + stopOpacity, + gradient = color.linearGradient || color.radialGradient, + x1, + y1, + x2, + y2, + opacity1, + opacity2, + color1, + color2, + fillAttr = '', + stops = color.stops, + firstStop, + lastStop, + colors = [], + addFillNode = function () { + // Add the fill subnode. When colors attribute is used, the meanings of opacity and o:opacity2 + // are reversed. + markup = ['']; + createElement(renderer.prepVML(markup), null, null, elem); + }; + + // Extend from 0 to 1 + firstStop = stops[0]; + lastStop = stops[stops.length - 1]; + if (firstStop[0] > 0) { + stops.unshift([ + 0, + firstStop[1] + ]); + } + if (lastStop[0] < 1) { + stops.push([ + 1, + lastStop[1] + ]); + } + + // Compute the stops + each(stops, function (stop, i) { + if (regexRgba.test(stop[1])) { + colorObject = Color(stop[1]); + stopColor = colorObject.get('rgb'); + stopOpacity = colorObject.get('a'); + } else { + stopColor = stop[1]; + stopOpacity = 1; + } + + // Build the color attribute + colors.push((stop[0] * 100) + '% ' + stopColor); + + // Only start and end opacities are allowed, so we use the first and the last + if (!i) { + opacity1 = stopOpacity; + color2 = stopColor; + } else { + opacity2 = stopOpacity; + color1 = stopColor; + } + }); + + // Apply the gradient to fills only. + if (prop === 'fill') { + + // Handle linear gradient angle + if (fillType === 'gradient') { + x1 = gradient.x1 || gradient[0] || 0; + y1 = gradient.y1 || gradient[1] || 0; + x2 = gradient.x2 || gradient[2] || 0; + y2 = gradient.y2 || gradient[3] || 0; + fillAttr = 'angle="' + (90 - math.atan( + (y2 - y1) / // y vector + (x2 - x1) // x vector + ) * 180 / mathPI) + '"'; + + addFillNode(); + + // Radial (circular) gradient + } else { + + var r = gradient.r, + sizex = r * 2, + sizey = r * 2, + cx = gradient.cx, + cy = gradient.cy, + radialReference = elem.radialReference, + bBox, + applyRadialGradient = function () { + if (radialReference) { + bBox = wrapper.getBBox(); + cx += (radialReference[0] - bBox.x) / bBox.width - 0.5; + cy += (radialReference[1] - bBox.y) / bBox.height - 0.5; + sizex *= radialReference[2] / bBox.width; + sizey *= radialReference[2] / bBox.height; + } + fillAttr = 'src="' + defaultOptions.global.VMLRadialGradientURL + '" ' + + 'size="' + sizex + ',' + sizey + '" ' + + 'origin="0.5,0.5" ' + + 'position="' + cx + ',' + cy + '" ' + + 'color2="' + color2 + '" '; + + addFillNode(); + }; + + // Apply radial gradient + if (wrapper.added) { + applyRadialGradient(); + } else { + // We need to know the bounding box to get the size and position right + wrapper.onAdd = applyRadialGradient; + } + + // The fill element's color attribute is broken in IE8 standards mode, so we + // need to set the parent shape's fillcolor attribute instead. + ret = color1; + } + + // Gradients are not supported for VML stroke, return the first color. #722. + } else { + ret = stopColor; + } + + // if the color is an rgba color, split it and add a fill node + // to hold the opacity component + } else if (regexRgba.test(color) && elem.tagName !== 'IMG') { + + colorObject = Color(color); + + markup = ['<', prop, ' opacity="', colorObject.get('a'), '"/>']; + createElement(this.prepVML(markup), null, null, elem); + + ret = colorObject.get('rgb'); + + + } else { + var propNodes = elem.getElementsByTagName(prop); // 'stroke' or 'fill' node + if (propNodes.length) { + propNodes[0].opacity = 1; + propNodes[0].type = 'solid'; + } + ret = color; + } + + return ret; + }, + + /** + * Take a VML string and prepare it for either IE8 or IE6/IE7. + * @param {Array} markup A string array of the VML markup to prepare + */ + prepVML: function (markup) { + var vmlStyle = 'display:inline-block;behavior:url(#default#VML);', + isIE8 = this.isIE8; + + markup = markup.join(''); + + if (isIE8) { // add xmlns and style inline + markup = markup.replace('/>', ' xmlns="urn:schemas-microsoft-com:vml" />'); + if (markup.indexOf('style="') === -1) { + markup = markup.replace('/>', ' style="' + vmlStyle + '" />'); + } else { + markup = markup.replace('style="', 'style="' + vmlStyle); + } + + } else { // add namespace + markup = markup.replace('<', ' 1) { + obj.attr({ + x: x, + y: y, + width: width, + height: height + }); + } + return obj; + }, + + /** + * For rectangles, VML uses a shape for rect to overcome bugs and rotation problems + */ + createElement: function (nodeName) { + return nodeName === 'rect' ? this.symbol(nodeName) : SVGRenderer.prototype.createElement.call(this, nodeName); + }, + + /** + * In the VML renderer, each child of an inverted div (group) is inverted + * @param {Object} element + * @param {Object} parentNode + */ + invertChild: function (element, parentNode) { + var ren = this, + parentStyle = parentNode.style, + imgStyle = element.tagName === 'IMG' && element.style; // #1111 + + css(element, { + flip: 'x', + left: pInt(parentStyle.width) - (imgStyle ? pInt(imgStyle.top) : 1), + top: pInt(parentStyle.height) - (imgStyle ? pInt(imgStyle.left) : 1), + rotation: -90 + }); + + // Recursively invert child elements, needed for nested composite shapes like box plots and error bars. #1680, #1806. + each(element.childNodes, function (child) { + ren.invertChild(child, element); + }); + }, + + /** + * Symbol definitions that override the parent SVG renderer's symbols + * + */ + symbols: { + // VML specific arc function + arc: function (x, y, w, h, options) { + var start = options.start, + end = options.end, + radius = options.r || w || h, + innerRadius = options.innerR, + cosStart = mathCos(start), + sinStart = mathSin(start), + cosEnd = mathCos(end), + sinEnd = mathSin(end), + ret; + + if (end - start === 0) { // no angle, don't show it. + return ['x']; + } + + ret = [ + 'wa', // clockwise arc to + x - radius, // left + y - radius, // top + x + radius, // right + y + radius, // bottom + x + radius * cosStart, // start x + y + radius * sinStart, // start y + x + radius * cosEnd, // end x + y + radius * sinEnd // end y + ]; + + if (options.open && !innerRadius) { + ret.push( + 'e', + M, + x,// - innerRadius, + y// - innerRadius + ); + } + + ret.push( + 'at', // anti clockwise arc to + x - innerRadius, // left + y - innerRadius, // top + x + innerRadius, // right + y + innerRadius, // bottom + x + innerRadius * cosEnd, // start x + y + innerRadius * sinEnd, // start y + x + innerRadius * cosStart, // end x + y + innerRadius * sinStart, // end y + 'x', // finish path + 'e' // close + ); + + ret.isArc = true; + return ret; + + }, + // Add circle symbol path. This performs significantly faster than v:oval. + circle: function (x, y, w, h, wrapper) { + + if (wrapper) { + w = h = 2 * wrapper.r; + } + + // Center correction, #1682 + if (wrapper && wrapper.isCircle) { + x -= w / 2; + y -= h / 2; + } + + // Return the path + return [ + 'wa', // clockwisearcto + x, // left + y, // top + x + w, // right + y + h, // bottom + x + w, // start x + y + h / 2, // start y + x + w, // end x + y + h / 2, // end y + //'x', // finish path + 'e' // close + ]; + }, + /** + * Add rectangle symbol path which eases rotation and omits arcsize problems + * compared to the built-in VML roundrect shape. When borders are not rounded, + * use the simpler square path, else use the callout path without the arrow. + */ + rect: function (x, y, w, h, options) { + return SVGRenderer.prototype.symbols[ + !defined(options) || !options.r ? 'square' : 'callout' + ].call(0, x, y, w, h, options); + } + } +}; +Highcharts.VMLRenderer = VMLRenderer = function () { + this.init.apply(this, arguments); +}; +VMLRenderer.prototype = merge(SVGRenderer.prototype, VMLRendererExtension); + + // general renderer + Renderer = VMLRenderer; +} + +// This method is used with exporting in old IE, when emulating SVG (see #2314) +SVGRenderer.prototype.measureSpanWidth = function (text, styles) { + var measuringSpan = doc.createElement('span'), + offsetWidth, + textNode = doc.createTextNode(text); + + measuringSpan.appendChild(textNode); + css(measuringSpan, styles); + this.box.appendChild(measuringSpan); + offsetWidth = measuringSpan.offsetWidth; + discardElement(measuringSpan); // #2463 + return offsetWidth; +}; + + +/* **************************************************************************** + * * + * END OF INTERNET EXPLORER <= 8 SPECIFIC CODE * + * * + *****************************************************************************/ +/* **************************************************************************** + * * + * START OF ANDROID < 3 SPECIFIC CODE. THIS CAN BE REMOVED IF YOU'RE NOT * + * TARGETING THAT SYSTEM. * + * * + *****************************************************************************/ +var CanVGRenderer, + CanVGController; + +if (useCanVG) { + /** + * The CanVGRenderer is empty from start to keep the source footprint small. + * When requested, the CanVGController downloads the rest of the source packaged + * together with the canvg library. + */ + Highcharts.CanVGRenderer = CanVGRenderer = function () { + // Override the global SVG namespace to fake SVG/HTML that accepts CSS + SVG_NS = 'http://www.w3.org/1999/xhtml'; + }; + + /** + * Start with an empty symbols object. This is needed when exporting is used (exporting.src.js will add a few symbols), but + * the implementation from SvgRenderer will not be merged in until first render. + */ + CanVGRenderer.prototype.symbols = {}; + + /** + * Handles on demand download of canvg rendering support. + */ + CanVGController = (function () { + // List of renderering calls + var deferredRenderCalls = []; + + /** + * When downloaded, we are ready to draw deferred charts. + */ + function drawDeferred() { + var callLength = deferredRenderCalls.length, + callIndex; + + // Draw all pending render calls + for (callIndex = 0; callIndex < callLength; callIndex++) { + deferredRenderCalls[callIndex](); + } + // Clear the list + deferredRenderCalls = []; + } + + return { + push: function (func, scriptLocation) { + // Only get the script once + if (deferredRenderCalls.length === 0) { + getScript(scriptLocation, drawDeferred); + } + // Register render call + deferredRenderCalls.push(func); + } + }; + }()); + + Renderer = CanVGRenderer; +} // end CanVGRenderer + +/* **************************************************************************** + * * + * END OF ANDROID < 3 SPECIFIC CODE * + * * + *****************************************************************************/ + +/** + * The Tick class + */ +function Tick(axis, pos, type, noLabel) { + this.axis = axis; + this.pos = pos; + this.type = type || ''; + this.isNew = true; + + if (!type && !noLabel) { + this.addLabel(); + } +} + +Tick.prototype = { + /** + * Write the tick label + */ + addLabel: function () { + var tick = this, + axis = tick.axis, + options = axis.options, + chart = axis.chart, + horiz = axis.horiz, + categories = axis.categories, + names = axis.names, + pos = tick.pos, + labelOptions = options.labels, + rotation = labelOptions.rotation, + str, + tickPositions = axis.tickPositions, + width = (horiz && categories && + !labelOptions.step && !labelOptions.staggerLines && + !labelOptions.rotation && + chart.plotWidth / tickPositions.length) || + (!horiz && (chart.margin[3] || chart.chartWidth * 0.33)), // #1580, #1931 + isFirst = pos === tickPositions[0], + isLast = pos === tickPositions[tickPositions.length - 1], + css, + attr, + value = categories ? + pick(categories[pos], names[pos], pos) : + pos, + label = tick.label, + tickPositionInfo = tickPositions.info, + dateTimeLabelFormat; + + // Set the datetime label format. If a higher rank is set for this position, use that. If not, + // use the general format. + if (axis.isDatetimeAxis && tickPositionInfo) { + dateTimeLabelFormat = options.dateTimeLabelFormats[tickPositionInfo.higherRanks[pos] || tickPositionInfo.unitName]; + } + // set properties for access in render method + tick.isFirst = isFirst; + tick.isLast = isLast; + + // get the string + str = axis.labelFormatter.call({ + axis: axis, + chart: chart, + isFirst: isFirst, + isLast: isLast, + dateTimeLabelFormat: dateTimeLabelFormat, + value: axis.isLog ? correctFloat(lin2log(value)) : value + }); + + // prepare CSS + css = width && { width: mathMax(1, mathRound(width - 2 * (labelOptions.padding || 10))) + PX }; + css = extend(css, labelOptions.style); + + // first call + if (!defined(label)) { + attr = { + align: axis.labelAlign + }; + if (isNumber(rotation)) { + attr.rotation = rotation; + } + if (width && labelOptions.ellipsis) { + css.HcHeight = axis.len / tickPositions.length; + } + + tick.label = label = + defined(str) && labelOptions.enabled ? + chart.renderer.text( + str, + 0, + 0, + labelOptions.useHTML + ) + .attr(attr) + // without position absolute, IE export sometimes is wrong + .css(css) + .add(axis.labelGroup) : + null; + + // Set the tick baseline and correct for rotation (#1764) + axis.tickBaseline = chart.renderer.fontMetrics(labelOptions.style.fontSize, label).b; + if (rotation && axis.side === 2) { + axis.tickBaseline *= mathCos(rotation * deg2rad); + } + + + // update + } else if (label) { + label.attr({ + text: str + }) + .css(css); + } + tick.yOffset = label ? pick(labelOptions.y, axis.tickBaseline + (axis.side === 2 ? 8 : -(label.getBBox().height / 2))) : 0; + }, + + /** + * Get the offset height or width of the label + */ + getLabelSize: function () { + var label = this.label, + axis = this.axis; + return label ? + label.getBBox()[axis.horiz ? 'height' : 'width'] : + 0; + }, + + /** + * Find how far the labels extend to the right and left of the tick's x position. Used for anti-collision + * detection with overflow logic. + */ + getLabelSides: function () { + var bBox = this.label.getBBox(), + axis = this.axis, + horiz = axis.horiz, + options = axis.options, + labelOptions = options.labels, + size = horiz ? bBox.width : bBox.height, + leftSide = horiz ? + labelOptions.x - size * { left: 0, center: 0.5, right: 1 }[axis.labelAlign] : + 0, + rightSide = horiz ? + size + leftSide : + size; + + return [leftSide, rightSide]; + }, + + /** + * Handle the label overflow by adjusting the labels to the left and right edge, or + * hide them if they collide into the neighbour label. + */ + handleOverflow: function (index, xy) { + var show = true, + axis = this.axis, + isFirst = this.isFirst, + isLast = this.isLast, + horiz = axis.horiz, + pxPos = horiz ? xy.x : xy.y, + reversed = axis.reversed, + tickPositions = axis.tickPositions, + sides = this.getLabelSides(), + leftSide = sides[0], + rightSide = sides[1], + axisLeft, + axisRight, + neighbour, + neighbourEdge, + line = this.label.line || 0, + labelEdge = axis.labelEdge, + justifyLabel = axis.justifyLabels && (isFirst || isLast), + justifyToPlot; + + // Hide it if it now overlaps the neighbour label + if (labelEdge[line] === UNDEFINED || pxPos + leftSide > labelEdge[line]) { + labelEdge[line] = pxPos + rightSide; + + } else if (!justifyLabel) { + show = false; + } + + if (justifyLabel) { + justifyToPlot = axis.justifyToPlot; + axisLeft = justifyToPlot ? axis.pos : 0; + axisRight = justifyToPlot ? axisLeft + axis.len : axis.chart.chartWidth; + + // Find the firsth neighbour on the same line + do { + index += (isFirst ? 1 : -1); + neighbour = axis.ticks[tickPositions[index]]; + } while (tickPositions[index] && (!neighbour || !neighbour.label || neighbour.label.line !== line)); // #3044 + + neighbourEdge = neighbour && neighbour.label.xy && neighbour.label.xy.x + neighbour.getLabelSides()[isFirst ? 0 : 1]; + + if ((isFirst && !reversed) || (isLast && reversed)) { + // Is the label spilling out to the left of the plot area? + if (pxPos + leftSide < axisLeft) { + + // Align it to plot left + pxPos = axisLeft - leftSide; + + // Hide it if it now overlaps the neighbour label + if (neighbour && pxPos + rightSide > neighbourEdge) { + show = false; + } + } + + } else { + // Is the label spilling out to the right of the plot area? + if (pxPos + rightSide > axisRight) { + + // Align it to plot right + pxPos = axisRight - rightSide; + + // Hide it if it now overlaps the neighbour label + if (neighbour && pxPos + leftSide < neighbourEdge) { + show = false; + } + + } + } + + // Set the modified x position of the label + xy.x = pxPos; + } + return show; + }, + + /** + * Get the x and y position for ticks and labels + */ + getPosition: function (horiz, pos, tickmarkOffset, old) { + var axis = this.axis, + chart = axis.chart, + cHeight = (old && chart.oldChartHeight) || chart.chartHeight; + + return { + x: horiz ? + axis.translate(pos + tickmarkOffset, null, null, old) + axis.transB : + axis.left + axis.offset + (axis.opposite ? ((old && chart.oldChartWidth) || chart.chartWidth) - axis.right - axis.left : 0), + + y: horiz ? + cHeight - axis.bottom + axis.offset - (axis.opposite ? axis.height : 0) : + cHeight - axis.translate(pos + tickmarkOffset, null, null, old) - axis.transB + }; + + }, + + /** + * Get the x, y position of the tick label + */ + getLabelPosition: function (x, y, label, horiz, labelOptions, tickmarkOffset, index, step) { + var axis = this.axis, + transA = axis.transA, + reversed = axis.reversed, + staggerLines = axis.staggerLines; + + x = x + labelOptions.x - (tickmarkOffset && horiz ? + tickmarkOffset * transA * (reversed ? -1 : 1) : 0); + y = y + this.yOffset - (tickmarkOffset && !horiz ? + tickmarkOffset * transA * (reversed ? 1 : -1) : 0); + + // Correct for staggered labels + if (staggerLines) { + label.line = (index / (step || 1) % staggerLines); + y += label.line * (axis.labelOffset / staggerLines); + } + + return { + x: x, + y: y + }; + }, + + /** + * Extendible method to return the path of the marker + */ + getMarkPath: function (x, y, tickLength, tickWidth, horiz, renderer) { + return renderer.crispLine([ + M, + x, + y, + L, + x + (horiz ? 0 : -tickLength), + y + (horiz ? tickLength : 0) + ], tickWidth); + }, + + /** + * Put everything in place + * + * @param index {Number} + * @param old {Boolean} Use old coordinates to prepare an animation into new position + */ + render: function (index, old, opacity) { + var tick = this, + axis = tick.axis, + options = axis.options, + chart = axis.chart, + renderer = chart.renderer, + horiz = axis.horiz, + type = tick.type, + label = tick.label, + pos = tick.pos, + labelOptions = options.labels, + gridLine = tick.gridLine, + gridPrefix = type ? type + 'Grid' : 'grid', + tickPrefix = type ? type + 'Tick' : 'tick', + gridLineWidth = options[gridPrefix + 'LineWidth'], + gridLineColor = options[gridPrefix + 'LineColor'], + dashStyle = options[gridPrefix + 'LineDashStyle'], + tickLength = options[tickPrefix + 'Length'], + tickWidth = options[tickPrefix + 'Width'] || 0, + tickColor = options[tickPrefix + 'Color'], + tickPosition = options[tickPrefix + 'Position'], + gridLinePath, + mark = tick.mark, + markPath, + step = labelOptions.step, + attribs, + show = true, + tickmarkOffset = axis.tickmarkOffset, + xy = tick.getPosition(horiz, pos, tickmarkOffset, old), + x = xy.x, + y = xy.y, + reverseCrisp = ((horiz && x === axis.pos + axis.len) || (!horiz && y === axis.pos)) ? -1 : 1; // #1480, #1687 + + opacity = pick(opacity, 1); + this.isActive = true; + + // create the grid line + if (gridLineWidth) { + gridLinePath = axis.getPlotLinePath(pos + tickmarkOffset, gridLineWidth * reverseCrisp, old, true); + + if (gridLine === UNDEFINED) { + attribs = { + stroke: gridLineColor, + 'stroke-width': gridLineWidth + }; + if (dashStyle) { + attribs.dashstyle = dashStyle; + } + if (!type) { + attribs.zIndex = 1; + } + if (old) { + attribs.opacity = 0; + } + tick.gridLine = gridLine = + gridLineWidth ? + renderer.path(gridLinePath) + .attr(attribs).add(axis.gridGroup) : + null; + } + + // If the parameter 'old' is set, the current call will be followed + // by another call, therefore do not do any animations this time + if (!old && gridLine && gridLinePath) { + gridLine[tick.isNew ? 'attr' : 'animate']({ + d: gridLinePath, + opacity: opacity + }); + } + } + + // create the tick mark + if (tickWidth && tickLength) { + + // negate the length + if (tickPosition === 'inside') { + tickLength = -tickLength; + } + if (axis.opposite) { + tickLength = -tickLength; + } + + markPath = tick.getMarkPath(x, y, tickLength, tickWidth * reverseCrisp, horiz, renderer); + if (mark) { // updating + mark.animate({ + d: markPath, + opacity: opacity + }); + } else { // first time + tick.mark = renderer.path( + markPath + ).attr({ + stroke: tickColor, + 'stroke-width': tickWidth, + opacity: opacity + }).add(axis.axisGroup); + } + } + + // the label is created on init - now move it into place + if (label && !isNaN(x)) { + label.xy = xy = tick.getLabelPosition(x, y, label, horiz, labelOptions, tickmarkOffset, index, step); + + // Apply show first and show last. If the tick is both first and last, it is + // a single centered tick, in which case we show the label anyway (#2100). + if ((tick.isFirst && !tick.isLast && !pick(options.showFirstLabel, 1)) || + (tick.isLast && !tick.isFirst && !pick(options.showLastLabel, 1))) { + show = false; + + // Handle label overflow and show or hide accordingly + } else if (!axis.isRadial && !labelOptions.step && !labelOptions.rotation && !old && opacity !== 0) { + show = tick.handleOverflow(index, xy); + } + + // apply step + if (step && index % step) { + // show those indices dividable by step + show = false; + } + + // Set the new position, and show or hide + if (show && !isNaN(xy.y)) { + xy.opacity = opacity; + label[tick.isNew ? 'attr' : 'animate'](xy); + tick.isNew = false; + } else { + label.attr('y', -9999); // #1338 + } + } + }, + + /** + * Destructor for the tick prototype + */ + destroy: function () { + destroyObjectProperties(this, this.axis); + } +}; + +/** + * The object wrapper for plot lines and plot bands + * @param {Object} options + */ +Highcharts.PlotLineOrBand = function (axis, options) { + this.axis = axis; + + if (options) { + this.options = options; + this.id = options.id; + } +}; + +Highcharts.PlotLineOrBand.prototype = { + + /** + * Render the plot line or plot band. If it is already existing, + * move it. + */ + render: function () { + var plotLine = this, + axis = plotLine.axis, + horiz = axis.horiz, + halfPointRange = (axis.pointRange || 0) / 2, + options = plotLine.options, + optionsLabel = options.label, + label = plotLine.label, + width = options.width, + to = options.to, + from = options.from, + isBand = defined(from) && defined(to), + value = options.value, + dashStyle = options.dashStyle, + svgElem = plotLine.svgElem, + path = [], + addEvent, + eventType, + xs, + ys, + x, + y, + color = options.color, + zIndex = options.zIndex, + events = options.events, + attribs = {}, + renderer = axis.chart.renderer; + + // logarithmic conversion + if (axis.isLog) { + from = log2lin(from); + to = log2lin(to); + value = log2lin(value); + } + + // plot line + if (width) { + path = axis.getPlotLinePath(value, width); + attribs = { + stroke: color, + 'stroke-width': width + }; + if (dashStyle) { + attribs.dashstyle = dashStyle; + } + } else if (isBand) { // plot band + + // keep within plot area + from = mathMax(from, axis.min - halfPointRange); + to = mathMin(to, axis.max + halfPointRange); + + path = axis.getPlotBandPath(from, to, options); + if (color) { + attribs.fill = color; + } + if (options.borderWidth) { + attribs.stroke = options.borderColor; + attribs['stroke-width'] = options.borderWidth; + } + } else { + return; + } + // zIndex + if (defined(zIndex)) { + attribs.zIndex = zIndex; + } + + // common for lines and bands + if (svgElem) { + if (path) { + svgElem.animate({ + d: path + }, null, svgElem.onGetPath); + } else { + svgElem.hide(); + svgElem.onGetPath = function () { + svgElem.show(); + }; + if (label) { + plotLine.label = label = label.destroy(); + } + } + } else if (path && path.length) { + plotLine.svgElem = svgElem = renderer.path(path) + .attr(attribs).add(); + + // events + if (events) { + addEvent = function (eventType) { + svgElem.on(eventType, function (e) { + events[eventType].apply(plotLine, [e]); + }); + }; + for (eventType in events) { + addEvent(eventType); + } + } + } + + // the plot band/line label + if (optionsLabel && defined(optionsLabel.text) && path && path.length && axis.width > 0 && axis.height > 0) { + // apply defaults + optionsLabel = merge({ + align: horiz && isBand && 'center', + x: horiz ? !isBand && 4 : 10, + verticalAlign : !horiz && isBand && 'middle', + y: horiz ? isBand ? 16 : 10 : isBand ? 6 : -4, + rotation: horiz && !isBand && 90 + }, optionsLabel); + + // add the SVG element + if (!label) { + attribs = { + align: optionsLabel.textAlign || optionsLabel.align, + rotation: optionsLabel.rotation + }; + if (defined(zIndex)) { + attribs.zIndex = zIndex; + } + plotLine.label = label = renderer.text( + optionsLabel.text, + 0, + 0, + optionsLabel.useHTML + ) + .attr(attribs) + .css(optionsLabel.style) + .add(); + } + + // get the bounding box and align the label + // #3000 changed to better handle choice between plotband or plotline + xs = [path[1], path[4], (isBand ? path[6] : path[1])]; + ys = [path[2], path[5], (isBand ? path[7] : path[2])]; + x = arrayMin(xs); + y = arrayMin(ys); + + label.align(optionsLabel, false, { + x: x, + y: y, + width: arrayMax(xs) - x, + height: arrayMax(ys) - y + }); + label.show(); + + } else if (label) { // move out of sight + label.hide(); + } + + // chainable + return plotLine; + }, + + /** + * Remove the plot line or band + */ + destroy: function () { + // remove it from the lookup + erase(this.axis.plotLinesAndBands, this); + + delete this.axis; + destroyObjectProperties(this); + } +}; + +/** + * Object with members for extending the Axis prototype + */ + +AxisPlotLineOrBandExtension = { + + /** + * Create the path for a plot band + */ + getPlotBandPath: function (from, to) { + var toPath = this.getPlotLinePath(to), + path = this.getPlotLinePath(from); + + if (path && toPath) { + path.push( + toPath[4], + toPath[5], + toPath[1], + toPath[2] + ); + } else { // outside the axis area + path = null; + } + + return path; + }, + + addPlotBand: function (options) { + return this.addPlotBandOrLine(options, 'plotBands'); + }, + + addPlotLine: function (options) { + return this.addPlotBandOrLine(options, 'plotLines'); + }, + + /** + * Add a plot band or plot line after render time + * + * @param options {Object} The plotBand or plotLine configuration object + */ + addPlotBandOrLine: function (options, coll) { + var obj = new Highcharts.PlotLineOrBand(this, options).render(), + userOptions = this.userOptions; + + if (obj) { // #2189 + // Add it to the user options for exporting and Axis.update + if (coll) { + userOptions[coll] = userOptions[coll] || []; + userOptions[coll].push(options); + } + this.plotLinesAndBands.push(obj); + } + + return obj; + }, + + /** + * Remove a plot band or plot line from the chart by id + * @param {Object} id + */ + removePlotBandOrLine: function (id) { + var plotLinesAndBands = this.plotLinesAndBands, + options = this.options, + userOptions = this.userOptions, + i = plotLinesAndBands.length; + while (i--) { + if (plotLinesAndBands[i].id === id) { + plotLinesAndBands[i].destroy(); + } + } + each([options.plotLines || [], userOptions.plotLines || [], options.plotBands || [], userOptions.plotBands || []], function (arr) { + i = arr.length; + while (i--) { + if (arr[i].id === id) { + erase(arr, arr[i]); + } + } + }); + } +}; + +/** + * Create a new axis object + * @param {Object} chart + * @param {Object} options + */ +function Axis() { + this.init.apply(this, arguments); +} + +Axis.prototype = { + + /** + * Default options for the X axis - the Y axis has extended defaults + */ + defaultOptions: { + // allowDecimals: null, + // alternateGridColor: null, + // categories: [], + dateTimeLabelFormats: { + millisecond: '%H:%M:%S.%L', + second: '%H:%M:%S', + minute: '%H:%M', + hour: '%H:%M', + day: '%e. %b', + week: '%e. %b', + month: '%b \'%y', + year: '%Y' + }, + endOnTick: false, + gridLineColor: '#C0C0C0', + // gridLineDashStyle: 'solid', + // gridLineWidth: 0, + // reversed: false, + + labels: defaultLabelOptions, + // { step: null }, + lineColor: '#C0D0E0', + lineWidth: 1, + //linkedTo: null, + //max: undefined, + //min: undefined, + minPadding: 0.01, + maxPadding: 0.01, + //minRange: null, + minorGridLineColor: '#E0E0E0', + // minorGridLineDashStyle: null, + minorGridLineWidth: 1, + minorTickColor: '#A0A0A0', + //minorTickInterval: null, + minorTickLength: 2, + minorTickPosition: 'outside', // inside or outside + //minorTickWidth: 0, + //opposite: false, + //offset: 0, + //plotBands: [{ + // events: {}, + // zIndex: 1, + // labels: { align, x, verticalAlign, y, style, rotation, textAlign } + //}], + //plotLines: [{ + // events: {} + // dashStyle: {} + // zIndex: + // labels: { align, x, verticalAlign, y, style, rotation, textAlign } + //}], + //reversed: false, + // showFirstLabel: true, + // showLastLabel: true, + startOfWeek: 1, + startOnTick: false, + tickColor: '#C0D0E0', + //tickInterval: null, + tickLength: 10, + tickmarkPlacement: 'between', // on or between + tickPixelInterval: 100, + tickPosition: 'outside', + tickWidth: 1, + title: { + //text: null, + align: 'middle', // low, middle or high + //margin: 0 for horizontal, 10 for vertical axes, + //rotation: 0, + //side: 'outside', + style: { + color: '#707070' + } + //x: 0, + //y: 0 + }, + type: 'linear' // linear, logarithmic or datetime + }, + + /** + * This options set extends the defaultOptions for Y axes + */ + defaultYAxisOptions: { + endOnTick: true, + gridLineWidth: 1, + tickPixelInterval: 72, + showLastLabel: true, + labels: { + x: -8, + y: 3 + }, + lineWidth: 0, + maxPadding: 0.05, + minPadding: 0.05, + startOnTick: true, + tickWidth: 0, + title: { + rotation: 270, + text: 'Values' + }, + stackLabels: { + enabled: false, + //align: dynamic, + //y: dynamic, + //x: dynamic, + //verticalAlign: dynamic, + //textAlign: dynamic, + //rotation: 0, + formatter: function () { + return numberFormat(this.total, -1); + }, + style: defaultLabelOptions.style + } + }, + + /** + * These options extend the defaultOptions for left axes + */ + defaultLeftAxisOptions: { + labels: { + x: -15, + y: null + }, + title: { + rotation: 270 + } + }, + + /** + * These options extend the defaultOptions for right axes + */ + defaultRightAxisOptions: { + labels: { + x: 15, + y: null + }, + title: { + rotation: 90 + } + }, + + /** + * These options extend the defaultOptions for bottom axes + */ + defaultBottomAxisOptions: { + labels: { + x: 0, + y: null // based on font size + // overflow: undefined, + // staggerLines: null + }, + title: { + rotation: 0 + } + }, + /** + * These options extend the defaultOptions for left axes + */ + defaultTopAxisOptions: { + labels: { + x: 0, + y: -15 + // overflow: undefined + // staggerLines: null + }, + title: { + rotation: 0 + } + }, + + /** + * Initialize the axis + */ + init: function (chart, userOptions) { + + + var isXAxis = userOptions.isX, + axis = this; + + // Flag, is the axis horizontal + axis.horiz = chart.inverted ? !isXAxis : isXAxis; + + // Flag, isXAxis + axis.isXAxis = isXAxis; + axis.coll = isXAxis ? 'xAxis' : 'yAxis'; + + axis.opposite = userOptions.opposite; // needed in setOptions + axis.side = userOptions.side || (axis.horiz ? + (axis.opposite ? 0 : 2) : // top : bottom + (axis.opposite ? 1 : 3)); // right : left + + axis.setOptions(userOptions); + + + var options = this.options, + type = options.type, + isDatetimeAxis = type === 'datetime'; + + axis.labelFormatter = options.labels.formatter || axis.defaultLabelFormatter; // can be overwritten by dynamic format + + + // Flag, stagger lines or not + axis.userOptions = userOptions; + + //axis.axisTitleMargin = UNDEFINED,// = options.title.margin, + axis.minPixelPadding = 0; + //axis.ignoreMinPadding = UNDEFINED; // can be set to true by a column or bar series + //axis.ignoreMaxPadding = UNDEFINED; + + axis.chart = chart; + axis.reversed = options.reversed; + axis.zoomEnabled = options.zoomEnabled !== false; + + // Initial categories + axis.categories = options.categories || type === 'category'; + axis.names = []; + + // Elements + //axis.axisGroup = UNDEFINED; + //axis.gridGroup = UNDEFINED; + //axis.axisTitle = UNDEFINED; + //axis.axisLine = UNDEFINED; + + // Shorthand types + axis.isLog = type === 'logarithmic'; + axis.isDatetimeAxis = isDatetimeAxis; + + // Flag, if axis is linked to another axis + axis.isLinked = defined(options.linkedTo); + // Linked axis. + //axis.linkedParent = UNDEFINED; + + // Tick positions + //axis.tickPositions = UNDEFINED; // array containing predefined positions + // Tick intervals + //axis.tickInterval = UNDEFINED; + //axis.minorTickInterval = UNDEFINED; + + axis.tickmarkOffset = (axis.categories && options.tickmarkPlacement === 'between') ? 0.5 : 0; + + // Major ticks + axis.ticks = {}; + axis.labelEdge = []; + // Minor ticks + axis.minorTicks = {}; + //axis.tickAmount = UNDEFINED; + + // List of plotLines/Bands + axis.plotLinesAndBands = []; + + // Alternate bands + axis.alternateBands = {}; + + // Axis metrics + //axis.left = UNDEFINED; + //axis.top = UNDEFINED; + //axis.width = UNDEFINED; + //axis.height = UNDEFINED; + //axis.bottom = UNDEFINED; + //axis.right = UNDEFINED; + //axis.transA = UNDEFINED; + //axis.transB = UNDEFINED; + //axis.oldTransA = UNDEFINED; + axis.len = 0; + //axis.oldMin = UNDEFINED; + //axis.oldMax = UNDEFINED; + //axis.oldUserMin = UNDEFINED; + //axis.oldUserMax = UNDEFINED; + //axis.oldAxisLength = UNDEFINED; + axis.minRange = axis.userMinRange = options.minRange || options.maxZoom; + axis.range = options.range; + axis.offset = options.offset || 0; + + + // Dictionary for stacks + axis.stacks = {}; + axis.oldStacks = {}; + + // Min and max in the data + //axis.dataMin = UNDEFINED, + //axis.dataMax = UNDEFINED, + + // The axis range + axis.max = null; + axis.min = null; + + // User set min and max + //axis.userMin = UNDEFINED, + //axis.userMax = UNDEFINED, + + // Crosshair options + axis.crosshair = pick(options.crosshair, splat(chart.options.tooltip.crosshairs)[isXAxis ? 0 : 1], false); + // Run Axis + + var eventType, + events = axis.options.events; + + // Register + if (inArray(axis, chart.axes) === -1) { // don't add it again on Axis.update() + if (isXAxis && !this.isColorAxis) { // #2713 + chart.axes.splice(chart.xAxis.length, 0, axis); + } else { + chart.axes.push(axis); + } + + chart[axis.coll].push(axis); + } + + axis.series = axis.series || []; // populated by Series + + // inverted charts have reversed xAxes as default + if (chart.inverted && isXAxis && axis.reversed === UNDEFINED) { + axis.reversed = true; + } + + axis.removePlotBand = axis.removePlotBandOrLine; + axis.removePlotLine = axis.removePlotBandOrLine; + + + // register event listeners + for (eventType in events) { + addEvent(axis, eventType, events[eventType]); + } + + // extend logarithmic axis + if (axis.isLog) { + axis.val2lin = log2lin; + axis.lin2val = lin2log; + } + }, + + /** + * Merge and set options + */ + setOptions: function (userOptions) { + this.options = merge( + this.defaultOptions, + this.isXAxis ? {} : this.defaultYAxisOptions, + [this.defaultTopAxisOptions, this.defaultRightAxisOptions, + this.defaultBottomAxisOptions, this.defaultLeftAxisOptions][this.side], + merge( + defaultOptions[this.coll], // if set in setOptions (#1053) + userOptions + ) + ); + }, + + /** + * The default label formatter. The context is a special config object for the label. + */ + defaultLabelFormatter: function () { + var axis = this.axis, + value = this.value, + categories = axis.categories, + dateTimeLabelFormat = this.dateTimeLabelFormat, + numericSymbols = defaultOptions.lang.numericSymbols, + i = numericSymbols && numericSymbols.length, + multi, + ret, + formatOption = axis.options.labels.format, + + // make sure the same symbol is added for all labels on a linear axis + numericSymbolDetector = axis.isLog ? value : axis.tickInterval; + + if (formatOption) { + ret = format(formatOption, this); + + } else if (categories) { + ret = value; + + } else if (dateTimeLabelFormat) { // datetime axis + ret = dateFormat(dateTimeLabelFormat, value); + + } else if (i && numericSymbolDetector >= 1000) { + // Decide whether we should add a numeric symbol like k (thousands) or M (millions). + // If we are to enable this in tooltip or other places as well, we can move this + // logic to the numberFormatter and enable it by a parameter. + while (i-- && ret === UNDEFINED) { + multi = Math.pow(1000, i + 1); + if (numericSymbolDetector >= multi && numericSymbols[i] !== null) { + ret = numberFormat(value / multi, -1) + numericSymbols[i]; + } + } + } + + if (ret === UNDEFINED) { + if (mathAbs(value) >= 10000) { // add thousands separators + ret = numberFormat(value, 0); + + } else { // small numbers + ret = numberFormat(value, -1, UNDEFINED, ''); // #2466 + } + } + + return ret; + }, + + /** + * Get the minimum and maximum for the series of each axis + */ + getSeriesExtremes: function () { + var axis = this, + chart = axis.chart; + + axis.hasVisibleSeries = false; + + // reset dataMin and dataMax in case we're redrawing + axis.dataMin = axis.dataMax = null; + + if (axis.buildStacks) { + axis.buildStacks(); + } + + // loop through this axis' series + each(axis.series, function (series) { + + if (series.visible || !chart.options.chart.ignoreHiddenSeries) { + + var seriesOptions = series.options, + xData, + threshold = seriesOptions.threshold, + seriesDataMin, + seriesDataMax; + + axis.hasVisibleSeries = true; + + // Validate threshold in logarithmic axes + if (axis.isLog && threshold <= 0) { + threshold = null; + } + + // Get dataMin and dataMax for X axes + if (axis.isXAxis) { + xData = series.xData; + if (xData.length) { + axis.dataMin = mathMin(pick(axis.dataMin, xData[0]), arrayMin(xData)); + axis.dataMax = mathMax(pick(axis.dataMax, xData[0]), arrayMax(xData)); + } + + // Get dataMin and dataMax for Y axes, as well as handle stacking and processed data + } else { + + // Get this particular series extremes + series.getExtremes(); + seriesDataMax = series.dataMax; + seriesDataMin = series.dataMin; + + // Get the dataMin and dataMax so far. If percentage is used, the min and max are + // always 0 and 100. If seriesDataMin and seriesDataMax is null, then series + // doesn't have active y data, we continue with nulls + if (defined(seriesDataMin) && defined(seriesDataMax)) { + axis.dataMin = mathMin(pick(axis.dataMin, seriesDataMin), seriesDataMin); + axis.dataMax = mathMax(pick(axis.dataMax, seriesDataMax), seriesDataMax); + } + + // Adjust to threshold + if (defined(threshold)) { + if (axis.dataMin >= threshold) { + axis.dataMin = threshold; + axis.ignoreMinPadding = true; + } else if (axis.dataMax < threshold) { + axis.dataMax = threshold; + axis.ignoreMaxPadding = true; + } + } + } + } + }); + }, + + /** + * Translate from axis value to pixel position on the chart, or back + * + */ + translate: function (val, backwards, cvsCoord, old, handleLog, pointPlacement) { + var axis = this, + sign = 1, + cvsOffset = 0, + localA = old ? axis.oldTransA : axis.transA, + localMin = old ? axis.oldMin : axis.min, + returnValue, + minPixelPadding = axis.minPixelPadding, + postTranslate = (axis.options.ordinal || (axis.isLog && handleLog)) && axis.lin2val; + + if (!localA) { + localA = axis.transA; + } + + // In vertical axes, the canvas coordinates start from 0 at the top like in + // SVG. + if (cvsCoord) { + sign *= -1; // canvas coordinates inverts the value + cvsOffset = axis.len; + } + + // Handle reversed axis + if (axis.reversed) { + sign *= -1; + cvsOffset -= sign * (axis.sector || axis.len); + } + + // From pixels to value + if (backwards) { // reverse translation + + val = val * sign + cvsOffset; + val -= minPixelPadding; + returnValue = val / localA + localMin; // from chart pixel to value + if (postTranslate) { // log and ordinal axes + returnValue = axis.lin2val(returnValue); + } + + // From value to pixels + } else { + if (postTranslate) { // log and ordinal axes + val = axis.val2lin(val); + } + if (pointPlacement === 'between') { + pointPlacement = 0.5; + } + returnValue = sign * (val - localMin) * localA + cvsOffset + (sign * minPixelPadding) + + (isNumber(pointPlacement) ? localA * pointPlacement * axis.pointRange : 0); + } + + return returnValue; + }, + + /** + * Utility method to translate an axis value to pixel position. + * @param {Number} value A value in terms of axis units + * @param {Boolean} paneCoordinates Whether to return the pixel coordinate relative to the chart + * or just the axis/pane itself. + */ + toPixels: function (value, paneCoordinates) { + return this.translate(value, false, !this.horiz, null, true) + (paneCoordinates ? 0 : this.pos); + }, + + /* + * Utility method to translate a pixel position in to an axis value + * @param {Number} pixel The pixel value coordinate + * @param {Boolean} paneCoordiantes Whether the input pixel is relative to the chart or just the + * axis/pane itself. + */ + toValue: function (pixel, paneCoordinates) { + return this.translate(pixel - (paneCoordinates ? 0 : this.pos), true, !this.horiz, null, true); + }, + + /** + * Create the path for a plot line that goes from the given value on + * this axis, across the plot to the opposite side + * @param {Number} value + * @param {Number} lineWidth Used for calculation crisp line + * @param {Number] old Use old coordinates (for resizing and rescaling) + */ + getPlotLinePath: function (value, lineWidth, old, force, translatedValue) { + var axis = this, + chart = axis.chart, + axisLeft = axis.left, + axisTop = axis.top, + x1, + y1, + x2, + y2, + cHeight = (old && chart.oldChartHeight) || chart.chartHeight, + cWidth = (old && chart.oldChartWidth) || chart.chartWidth, + skip, + transB = axis.transB; + + translatedValue = pick(translatedValue, axis.translate(value, null, null, old)); + x1 = x2 = mathRound(translatedValue + transB); + y1 = y2 = mathRound(cHeight - translatedValue - transB); + + if (isNaN(translatedValue)) { // no min or max + skip = true; + + } else if (axis.horiz) { + y1 = axisTop; + y2 = cHeight - axis.bottom; + if (x1 < axisLeft || x1 > axisLeft + axis.width) { + skip = true; + } + } else { + x1 = axisLeft; + x2 = cWidth - axis.right; + + if (y1 < axisTop || y1 > axisTop + axis.height) { + skip = true; + } + } + return skip && !force ? + null : + chart.renderer.crispLine([M, x1, y1, L, x2, y2], lineWidth || 1); + }, + + /** + * Set the tick positions of a linear axis to round values like whole tens or every five. + */ + getLinearTickPositions: function (tickInterval, min, max) { + var pos, + lastPos, + roundedMin = correctFloat(mathFloor(min / tickInterval) * tickInterval), + roundedMax = correctFloat(mathCeil(max / tickInterval) * tickInterval), + tickPositions = []; + + // For single points, add a tick regardless of the relative position (#2662) + if (min === max && isNumber(min)) { + return [min]; + } + + // Populate the intermediate values + pos = roundedMin; + while (pos <= roundedMax) { + + // Place the tick on the rounded value + tickPositions.push(pos); + + // Always add the raw tickInterval, not the corrected one. + pos = correctFloat(pos + tickInterval); + + // If the interval is not big enough in the current min - max range to actually increase + // the loop variable, we need to break out to prevent endless loop. Issue #619 + if (pos === lastPos) { + break; + } + + // Record the last value + lastPos = pos; + } + return tickPositions; + }, + + /** + * Return the minor tick positions. For logarithmic axes, reuse the same logic + * as for major ticks. + */ + getMinorTickPositions: function () { + var axis = this, + options = axis.options, + tickPositions = axis.tickPositions, + minorTickInterval = axis.minorTickInterval, + minorTickPositions = [], + pos, + i, + len; + + if (axis.isLog) { + len = tickPositions.length; + for (i = 1; i < len; i++) { + minorTickPositions = minorTickPositions.concat( + axis.getLogTickPositions(minorTickInterval, tickPositions[i - 1], tickPositions[i], true) + ); + } + } else if (axis.isDatetimeAxis && options.minorTickInterval === 'auto') { // #1314 + minorTickPositions = minorTickPositions.concat( + axis.getTimeTicks( + axis.normalizeTimeTickInterval(minorTickInterval), + axis.min, + axis.max, + options.startOfWeek + ) + ); + if (minorTickPositions[0] < axis.min) { + minorTickPositions.shift(); + } + } else { + for (pos = axis.min + (tickPositions[0] - axis.min) % minorTickInterval; pos <= axis.max; pos += minorTickInterval) { + minorTickPositions.push(pos); + } + } + return minorTickPositions; + }, + + /** + * Adjust the min and max for the minimum range. Keep in mind that the series data is + * not yet processed, so we don't have information on data cropping and grouping, or + * updated axis.pointRange or series.pointRange. The data can't be processed until + * we have finally established min and max. + */ + adjustForMinRange: function () { + var axis = this, + options = axis.options, + min = axis.min, + max = axis.max, + zoomOffset, + spaceAvailable = axis.dataMax - axis.dataMin >= axis.minRange, + closestDataRange, + i, + distance, + xData, + loopLength, + minArgs, + maxArgs; + + // Set the automatic minimum range based on the closest point distance + if (axis.isXAxis && axis.minRange === UNDEFINED && !axis.isLog) { + + if (defined(options.min) || defined(options.max)) { + axis.minRange = null; // don't do this again + + } else { + + // Find the closest distance between raw data points, as opposed to + // closestPointRange that applies to processed points (cropped and grouped) + each(axis.series, function (series) { + xData = series.xData; + loopLength = series.xIncrement ? 1 : xData.length - 1; + for (i = loopLength; i > 0; i--) { + distance = xData[i] - xData[i - 1]; + if (closestDataRange === UNDEFINED || distance < closestDataRange) { + closestDataRange = distance; + } + } + }); + axis.minRange = mathMin(closestDataRange * 5, axis.dataMax - axis.dataMin); + } + } + + // if minRange is exceeded, adjust + if (max - min < axis.minRange) { + var minRange = axis.minRange; + zoomOffset = (minRange - max + min) / 2; + + // if min and max options have been set, don't go beyond it + minArgs = [min - zoomOffset, pick(options.min, min - zoomOffset)]; + if (spaceAvailable) { // if space is available, stay within the data range + minArgs[2] = axis.dataMin; + } + min = arrayMax(minArgs); + + maxArgs = [min + minRange, pick(options.max, min + minRange)]; + if (spaceAvailable) { // if space is availabe, stay within the data range + maxArgs[2] = axis.dataMax; + } + + max = arrayMin(maxArgs); + + // now if the max is adjusted, adjust the min back + if (max - min < minRange) { + minArgs[0] = max - minRange; + minArgs[1] = pick(options.min, max - minRange); + min = arrayMax(minArgs); + } + } + + // Record modified extremes + axis.min = min; + axis.max = max; + }, + + /** + * Update translation information + */ + setAxisTranslation: function (saveOld) { + var axis = this, + range = axis.max - axis.min, + pointRange = axis.axisPointRange || 0, + closestPointRange, + minPointOffset = 0, + pointRangePadding = 0, + linkedParent = axis.linkedParent, + ordinalCorrection, + hasCategories = !!axis.categories, + transA = axis.transA; + + // Adjust translation for padding. Y axis with categories need to go through the same (#1784). + if (axis.isXAxis || hasCategories || pointRange) { + if (linkedParent) { + minPointOffset = linkedParent.minPointOffset; + pointRangePadding = linkedParent.pointRangePadding; + + } else { + each(axis.series, function (series) { + var seriesPointRange = hasCategories ? 1 : (axis.isXAxis ? series.pointRange : (axis.axisPointRange || 0)), // #2806 + pointPlacement = series.options.pointPlacement, + seriesClosestPointRange = series.closestPointRange; + + if (seriesPointRange > range) { // #1446 + seriesPointRange = 0; + } + pointRange = mathMax(pointRange, seriesPointRange); + + // minPointOffset is the value padding to the left of the axis in order to make + // room for points with a pointRange, typically columns. When the pointPlacement option + // is 'between' or 'on', this padding does not apply. + minPointOffset = mathMax( + minPointOffset, + isString(pointPlacement) ? 0 : seriesPointRange / 2 + ); + + // Determine the total padding needed to the length of the axis to make room for the + // pointRange. If the series' pointPlacement is 'on', no padding is added. + pointRangePadding = mathMax( + pointRangePadding, + pointPlacement === 'on' ? 0 : seriesPointRange + ); + + // Set the closestPointRange + if (!series.noSharedTooltip && defined(seriesClosestPointRange)) { + closestPointRange = defined(closestPointRange) ? + mathMin(closestPointRange, seriesClosestPointRange) : + seriesClosestPointRange; + } + }); + } + + // Record minPointOffset and pointRangePadding + ordinalCorrection = axis.ordinalSlope && closestPointRange ? axis.ordinalSlope / closestPointRange : 1; // #988, #1853 + axis.minPointOffset = minPointOffset = minPointOffset * ordinalCorrection; + axis.pointRangePadding = pointRangePadding = pointRangePadding * ordinalCorrection; + + // pointRange means the width reserved for each point, like in a column chart + axis.pointRange = mathMin(pointRange, range); + + // closestPointRange means the closest distance between points. In columns + // it is mostly equal to pointRange, but in lines pointRange is 0 while closestPointRange + // is some other value + axis.closestPointRange = closestPointRange; + } + + // Secondary values + if (saveOld) { + axis.oldTransA = transA; + } + axis.translationSlope = axis.transA = transA = axis.len / ((range + pointRangePadding) || 1); + axis.transB = axis.horiz ? axis.left : axis.bottom; // translation addend + axis.minPixelPadding = transA * minPointOffset; + }, + + /** + * Set the tick positions to round values and optionally extend the extremes + * to the nearest tick + */ + setTickPositions: function (secondPass) { + var axis = this, + chart = axis.chart, + options = axis.options, + startOnTick = options.startOnTick, + endOnTick = options.endOnTick, + isLog = axis.isLog, + isDatetimeAxis = axis.isDatetimeAxis, + isXAxis = axis.isXAxis, + isLinked = axis.isLinked, + tickPositioner = axis.options.tickPositioner, + maxPadding = options.maxPadding, + minPadding = options.minPadding, + length, + linkedParentExtremes, + tickIntervalOption = options.tickInterval, + minTickIntervalOption = options.minTickInterval, + tickPixelIntervalOption = options.tickPixelInterval, + tickPositions, + keepTwoTicksOnly, + categories = axis.categories; + + // linked axis gets the extremes from the parent axis + if (isLinked) { + axis.linkedParent = chart[axis.coll][options.linkedTo]; + linkedParentExtremes = axis.linkedParent.getExtremes(); + axis.min = pick(linkedParentExtremes.min, linkedParentExtremes.dataMin); + axis.max = pick(linkedParentExtremes.max, linkedParentExtremes.dataMax); + if (options.type !== axis.linkedParent.options.type) { + error(11, 1); // Can't link axes of different type + } + } else { // initial min and max from the extreme data values + axis.min = pick(axis.userMin, options.min, axis.dataMin); + axis.max = pick(axis.userMax, options.max, axis.dataMax); + } + + if (isLog) { + if (!secondPass && mathMin(axis.min, pick(axis.dataMin, axis.min)) <= 0) { // #978 + error(10, 1); // Can't plot negative values on log axis + } + axis.min = correctFloat(log2lin(axis.min)); // correctFloat cures #934 + axis.max = correctFloat(log2lin(axis.max)); + } + + // handle zoomed range + if (axis.range && defined(axis.max)) { + axis.userMin = axis.min = mathMax(axis.min, axis.max - axis.range); // #618 + axis.userMax = axis.max; + + axis.range = null; // don't use it when running setExtremes + } + + // Hook for adjusting this.min and this.max. Used by bubble series. + if (axis.beforePadding) { + axis.beforePadding(); + } + + // adjust min and max for the minimum range + axis.adjustForMinRange(); + + // Pad the values to get clear of the chart's edges. To avoid tickInterval taking the padding + // into account, we do this after computing tick interval (#1337). + if (!categories && !axis.axisPointRange && !axis.usePercentage && !isLinked && defined(axis.min) && defined(axis.max)) { + length = axis.max - axis.min; + if (length) { + if (!defined(options.min) && !defined(axis.userMin) && minPadding && (axis.dataMin < 0 || !axis.ignoreMinPadding)) { + axis.min -= length * minPadding; + } + if (!defined(options.max) && !defined(axis.userMax) && maxPadding && (axis.dataMax > 0 || !axis.ignoreMaxPadding)) { + axis.max += length * maxPadding; + } + } + } + + // Stay within floor and ceiling + if (isNumber(options.floor)) { + axis.min = mathMax(axis.min, options.floor); + } + if (isNumber(options.ceiling)) { + axis.max = mathMin(axis.max, options.ceiling); + } + + // get tickInterval + if (axis.min === axis.max || axis.min === undefined || axis.max === undefined) { + axis.tickInterval = 1; + } else if (isLinked && !tickIntervalOption && + tickPixelIntervalOption === axis.linkedParent.options.tickPixelInterval) { + axis.tickInterval = axis.linkedParent.tickInterval; + } else { + axis.tickInterval = pick( + tickIntervalOption, + categories ? // for categoried axis, 1 is default, for linear axis use tickPix + 1 : + // don't let it be more than the data range + (axis.max - axis.min) * tickPixelIntervalOption / mathMax(axis.len, tickPixelIntervalOption) + ); + // For squished axes, set only two ticks + if (!defined(tickIntervalOption) && axis.len < tickPixelIntervalOption && !this.isRadial && + !this.isLog && !categories && startOnTick && endOnTick) { + keepTwoTicksOnly = true; + axis.tickInterval /= 4; // tick extremes closer to the real values + } + } + + // Now we're finished detecting min and max, crop and group series data. This + // is in turn needed in order to find tick positions in ordinal axes. + if (isXAxis && !secondPass) { + each(axis.series, function (series) { + series.processData(axis.min !== axis.oldMin || axis.max !== axis.oldMax); + }); + } + + // set the translation factor used in translate function + axis.setAxisTranslation(true); + + // hook for ordinal axes and radial axes + if (axis.beforeSetTickPositions) { + axis.beforeSetTickPositions(); + } + + // hook for extensions, used in Highstock ordinal axes + if (axis.postProcessTickInterval) { + axis.tickInterval = axis.postProcessTickInterval(axis.tickInterval); + } + + // In column-like charts, don't cramp in more ticks than there are points (#1943) + if (axis.pointRange) { + axis.tickInterval = mathMax(axis.pointRange, axis.tickInterval); + } + + // Before normalizing the tick interval, handle minimum tick interval. This applies only if tickInterval is not defined. + if (!tickIntervalOption && axis.tickInterval < minTickIntervalOption) { + axis.tickInterval = minTickIntervalOption; + } + + // for linear axes, get magnitude and normalize the interval + if (!isDatetimeAxis && !isLog) { // linear + if (!tickIntervalOption) { + axis.tickInterval = normalizeTickInterval(axis.tickInterval, null, getMagnitude(axis.tickInterval), options); + } + } + + // get minorTickInterval + axis.minorTickInterval = options.minorTickInterval === 'auto' && axis.tickInterval ? + axis.tickInterval / 5 : options.minorTickInterval; + + // find the tick positions + axis.tickPositions = tickPositions = options.tickPositions ? + [].concat(options.tickPositions) : // Work on a copy (#1565) + (tickPositioner && tickPositioner.apply(axis, [axis.min, axis.max])); + if (!tickPositions) { + + // Too many ticks + if (!axis.ordinalPositions && (axis.max - axis.min) / axis.tickInterval > mathMax(2 * axis.len, 200)) { + error(19, true); + } + + if (isDatetimeAxis) { + tickPositions = axis.getTimeTicks( + axis.normalizeTimeTickInterval(axis.tickInterval, options.units), + axis.min, + axis.max, + options.startOfWeek, + axis.ordinalPositions, + axis.closestPointRange, + true + ); + } else if (isLog) { + tickPositions = axis.getLogTickPositions(axis.tickInterval, axis.min, axis.max); + } else { + tickPositions = axis.getLinearTickPositions(axis.tickInterval, axis.min, axis.max); + } + + if (keepTwoTicksOnly) { + tickPositions.splice(1, tickPositions.length - 2); + } + + axis.tickPositions = tickPositions; + } + + if (!isLinked) { + + // reset min/max or remove extremes based on start/end on tick + var roundedMin = tickPositions[0], + roundedMax = tickPositions[tickPositions.length - 1], + minPointOffset = axis.minPointOffset || 0, + singlePad; + + // Prevent all ticks from being removed (#3195) + if (!startOnTick && !endOnTick && !categories && tickPositions.length === 2) { + tickPositions.splice(1, 0, (roundedMax + roundedMin) / 2); + } + + if (startOnTick) { + axis.min = roundedMin; + } else if (axis.min - minPointOffset > roundedMin) { + tickPositions.shift(); + } + + if (endOnTick) { + axis.max = roundedMax; + } else if (axis.max + minPointOffset < roundedMax) { + tickPositions.pop(); + } + + // When there is only one point, or all points have the same value on this axis, then min + // and max are equal and tickPositions.length is 0 or 1. In this case, add some padding + // in order to center the point, but leave it with one tick. #1337. + if (tickPositions.length === 1) { + singlePad = mathAbs(axis.max) > 10e12 ? 1 : 0.001; // The lowest possible number to avoid extra padding on columns (#2619, #2846) + axis.min -= singlePad; + axis.max += singlePad; + } + } + }, + + /** + * Set the max ticks of either the x and y axis collection + */ + setMaxTicks: function () { + + var chart = this.chart, + maxTicks = chart.maxTicks || {}, + tickPositions = this.tickPositions, + key = this._maxTicksKey = [this.coll, this.pos, this.len].join('-'); + + if (!this.isLinked && !this.isDatetimeAxis && tickPositions && tickPositions.length > (maxTicks[key] || 0) && this.options.alignTicks !== false) { + maxTicks[key] = tickPositions.length; + } + chart.maxTicks = maxTicks; + }, + + /** + * When using multiple axes, adjust the number of ticks to match the highest + * number of ticks in that group + */ + adjustTickAmount: function () { + var axis = this, + chart = axis.chart, + key = axis._maxTicksKey, + tickPositions = axis.tickPositions, + maxTicks = chart.maxTicks; + + if (maxTicks && maxTicks[key] && !axis.isDatetimeAxis && !axis.categories && !axis.isLinked && + axis.options.alignTicks !== false && this.min !== UNDEFINED) { + var oldTickAmount = axis.tickAmount, + calculatedTickAmount = tickPositions.length, + tickAmount; + + // set the axis-level tickAmount to use below + axis.tickAmount = tickAmount = maxTicks[key]; + + if (calculatedTickAmount < tickAmount) { + while (tickPositions.length < tickAmount) { + tickPositions.push(correctFloat( + tickPositions[tickPositions.length - 1] + axis.tickInterval + )); + } + axis.transA *= (calculatedTickAmount - 1) / (tickAmount - 1); + axis.max = tickPositions[tickPositions.length - 1]; + + } + if (defined(oldTickAmount) && tickAmount !== oldTickAmount) { + axis.isDirty = true; + } + } + }, + + /** + * Set the scale based on data min and max, user set min and max or options + * + */ + setScale: function () { + var axis = this, + stacks = axis.stacks, + type, + i, + isDirtyData, + isDirtyAxisLength; + + axis.oldMin = axis.min; + axis.oldMax = axis.max; + axis.oldAxisLength = axis.len; + + // set the new axisLength + axis.setAxisSize(); + //axisLength = horiz ? axisWidth : axisHeight; + isDirtyAxisLength = axis.len !== axis.oldAxisLength; + + // is there new data? + each(axis.series, function (series) { + if (series.isDirtyData || series.isDirty || + series.xAxis.isDirty) { // when x axis is dirty, we need new data extremes for y as well + isDirtyData = true; + } + }); + + // do we really need to go through all this? + if (isDirtyAxisLength || isDirtyData || axis.isLinked || axis.forceRedraw || + axis.userMin !== axis.oldUserMin || axis.userMax !== axis.oldUserMax) { + + // reset stacks + if (!axis.isXAxis) { + for (type in stacks) { + for (i in stacks[type]) { + stacks[type][i].total = null; + stacks[type][i].cum = 0; + } + } + } + + axis.forceRedraw = false; + + // get data extremes if needed + axis.getSeriesExtremes(); + + // get fixed positions based on tickInterval + axis.setTickPositions(); + + // record old values to decide whether a rescale is necessary later on (#540) + axis.oldUserMin = axis.userMin; + axis.oldUserMax = axis.userMax; + + // Mark as dirty if it is not already set to dirty and extremes have changed. #595. + if (!axis.isDirty) { + axis.isDirty = isDirtyAxisLength || axis.min !== axis.oldMin || axis.max !== axis.oldMax; + } + } else if (!axis.isXAxis) { + if (axis.oldStacks) { + stacks = axis.stacks = axis.oldStacks; + } + + // reset stacks + for (type in stacks) { + for (i in stacks[type]) { + stacks[type][i].cum = stacks[type][i].total; + } + } + } + + // Set the maximum tick amount + axis.setMaxTicks(); + }, + + /** + * Set the extremes and optionally redraw + * @param {Number} newMin + * @param {Number} newMax + * @param {Boolean} redraw + * @param {Boolean|Object} animation Whether to apply animation, and optionally animation + * configuration + * @param {Object} eventArguments + * + */ + setExtremes: function (newMin, newMax, redraw, animation, eventArguments) { + var axis = this, + chart = axis.chart; + + redraw = pick(redraw, true); // defaults to true + + // Extend the arguments with min and max + eventArguments = extend(eventArguments, { + min: newMin, + max: newMax + }); + + // Fire the event + fireEvent(axis, 'setExtremes', eventArguments, function () { // the default event handler + + axis.userMin = newMin; + axis.userMax = newMax; + axis.eventArgs = eventArguments; + + // Mark for running afterSetExtremes + axis.isDirtyExtremes = true; + + // redraw + if (redraw) { + chart.redraw(animation); + } + }); + }, + + /** + * Overridable method for zooming chart. Pulled out in a separate method to allow overriding + * in stock charts. + */ + zoom: function (newMin, newMax) { + var dataMin = this.dataMin, + dataMax = this.dataMax, + options = this.options; + + // Prevent pinch zooming out of range. Check for defined is for #1946. #1734. + if (!this.allowZoomOutside) { + if (defined(dataMin) && newMin <= mathMin(dataMin, pick(options.min, dataMin))) { + newMin = UNDEFINED; + } + if (defined(dataMax) && newMax >= mathMax(dataMax, pick(options.max, dataMax))) { + newMax = UNDEFINED; + } + } + + // In full view, displaying the reset zoom button is not required + this.displayBtn = newMin !== UNDEFINED || newMax !== UNDEFINED; + + // Do it + this.setExtremes( + newMin, + newMax, + false, + UNDEFINED, + { trigger: 'zoom' } + ); + return true; + }, + + /** + * Update the axis metrics + */ + setAxisSize: function () { + var chart = this.chart, + options = this.options, + offsetLeft = options.offsetLeft || 0, + offsetRight = options.offsetRight || 0, + horiz = this.horiz, + width = pick(options.width, chart.plotWidth - offsetLeft + offsetRight), + height = pick(options.height, chart.plotHeight), + top = pick(options.top, chart.plotTop), + left = pick(options.left, chart.plotLeft + offsetLeft), + percentRegex = /%$/; + + // Check for percentage based input values + if (percentRegex.test(height)) { + height = parseInt(height, 10) / 100 * chart.plotHeight; + } + if (percentRegex.test(top)) { + top = parseInt(top, 10) / 100 * chart.plotHeight + chart.plotTop; + } + + // Expose basic values to use in Series object and navigator + this.left = left; + this.top = top; + this.width = width; + this.height = height; + this.bottom = chart.chartHeight - height - top; + this.right = chart.chartWidth - width - left; + + // Direction agnostic properties + this.len = mathMax(horiz ? width : height, 0); // mathMax fixes #905 + this.pos = horiz ? left : top; // distance from SVG origin + }, + + /** + * Get the actual axis extremes + */ + getExtremes: function () { + var axis = this, + isLog = axis.isLog; + + return { + min: isLog ? correctFloat(lin2log(axis.min)) : axis.min, + max: isLog ? correctFloat(lin2log(axis.max)) : axis.max, + dataMin: axis.dataMin, + dataMax: axis.dataMax, + userMin: axis.userMin, + userMax: axis.userMax + }; + }, + + /** + * Get the zero plane either based on zero or on the min or max value. + * Used in bar and area plots + */ + getThreshold: function (threshold) { + var axis = this, + isLog = axis.isLog; + + var realMin = isLog ? lin2log(axis.min) : axis.min, + realMax = isLog ? lin2log(axis.max) : axis.max; + + if (realMin > threshold || threshold === null) { + threshold = realMin; + } else if (realMax < threshold) { + threshold = realMax; + } + + return axis.translate(threshold, 0, 1, 0, 1); + }, + + /** + * Compute auto alignment for the axis label based on which side the axis is on + * and the given rotation for the label + */ + autoLabelAlign: function (rotation) { + var ret, + angle = (pick(rotation, 0) - (this.side * 90) + 720) % 360; + + if (angle > 15 && angle < 165) { + ret = 'right'; + } else if (angle > 195 && angle < 345) { + ret = 'left'; + } else { + ret = 'center'; + } + return ret; + }, + + /** + * Render the tick labels to a preliminary position to get their sizes + */ + getOffset: function () { + var axis = this, + chart = axis.chart, + renderer = chart.renderer, + options = axis.options, + tickPositions = axis.tickPositions, + ticks = axis.ticks, + horiz = axis.horiz, + side = axis.side, + invertedSide = chart.inverted ? [1, 0, 3, 2][side] : side, + hasData, + showAxis, + titleOffset = 0, + titleOffsetOption, + titleMargin = 0, + axisTitleOptions = options.title, + labelOptions = options.labels, + labelOffset = 0, // reset + labelOffsetPadded, + axisOffset = chart.axisOffset, + clipOffset = chart.clipOffset, + directionFactor = [-1, 1, 1, -1][side], + n, + i, + autoStaggerLines = 1, + maxStaggerLines = pick(labelOptions.maxStaggerLines, 5), + sortedPositions, + lastRight, + overlap, + pos, + bBox, + x, + w, + lineNo, + lineHeightCorrection; + + // For reuse in Axis.render + axis.hasData = hasData = (axis.hasVisibleSeries || (defined(axis.min) && defined(axis.max) && !!tickPositions)); + axis.showAxis = showAxis = hasData || pick(options.showEmpty, true); + + // Set/reset staggerLines + axis.staggerLines = axis.horiz && labelOptions.staggerLines; + + // Create the axisGroup and gridGroup elements on first iteration + if (!axis.axisGroup) { + axis.gridGroup = renderer.g('grid') + .attr({ zIndex: options.gridZIndex || 1 }) + .add(); + axis.axisGroup = renderer.g('axis') + .attr({ zIndex: options.zIndex || 2 }) + .add(); + axis.labelGroup = renderer.g('axis-labels') + .attr({ zIndex: labelOptions.zIndex || 7 }) + .addClass(PREFIX + axis.coll.toLowerCase() + '-labels') + .add(); + } + + if (hasData || axis.isLinked) { + + // Set the explicit or automatic label alignment + axis.labelAlign = pick(labelOptions.align || axis.autoLabelAlign(labelOptions.rotation)); + + // Generate ticks + each(tickPositions, function (pos) { + if (!ticks[pos]) { + ticks[pos] = new Tick(axis, pos); + } else { + ticks[pos].addLabel(); // update labels depending on tick interval + } + }); + + // Handle automatic stagger lines + if (axis.horiz && !axis.staggerLines && maxStaggerLines && !labelOptions.rotation) { + sortedPositions = axis.reversed ? [].concat(tickPositions).reverse() : tickPositions; + while (autoStaggerLines < maxStaggerLines) { + lastRight = []; + overlap = false; + + for (i = 0; i < sortedPositions.length; i++) { + pos = sortedPositions[i]; + bBox = ticks[pos].label && ticks[pos].label.getBBox(); + w = bBox ? bBox.width : 0; + lineNo = i % autoStaggerLines; + + if (w) { + x = axis.translate(pos); // don't handle log + if (lastRight[lineNo] !== UNDEFINED && x < lastRight[lineNo]) { + overlap = true; + } + lastRight[lineNo] = x + w; + } + } + if (overlap) { + autoStaggerLines++; + } else { + break; + } + } + + if (autoStaggerLines > 1) { + axis.staggerLines = autoStaggerLines; + } + } + + + each(tickPositions, function (pos) { + // left side must be align: right and right side must have align: left for labels + if (side === 0 || side === 2 || { 1: 'left', 3: 'right' }[side] === axis.labelAlign) { + + // get the highest offset + labelOffset = mathMax( + ticks[pos].getLabelSize(), + labelOffset + ); + } + }); + + if (axis.staggerLines) { + labelOffset *= axis.staggerLines; + axis.labelOffset = labelOffset; + } + + + } else { // doesn't have data + for (n in ticks) { + ticks[n].destroy(); + delete ticks[n]; + } + } + + if (axisTitleOptions && axisTitleOptions.text && axisTitleOptions.enabled !== false) { + if (!axis.axisTitle) { + axis.axisTitle = renderer.text( + axisTitleOptions.text, + 0, + 0, + axisTitleOptions.useHTML + ) + .attr({ + zIndex: 7, + rotation: axisTitleOptions.rotation || 0, + align: + axisTitleOptions.textAlign || + { low: 'left', middle: 'center', high: 'right' }[axisTitleOptions.align] + }) + .addClass(PREFIX + this.coll.toLowerCase() + '-title') + .css(axisTitleOptions.style) + .add(axis.axisGroup); + axis.axisTitle.isNew = true; + } + + if (showAxis) { + titleOffset = axis.axisTitle.getBBox()[horiz ? 'height' : 'width']; + titleOffsetOption = axisTitleOptions.offset; + titleMargin = defined(titleOffsetOption) ? 0 : pick(axisTitleOptions.margin, horiz ? 5 : 10); + } + + // hide or show the title depending on whether showEmpty is set + axis.axisTitle[showAxis ? 'show' : 'hide'](); + } + + // handle automatic or user set offset + axis.offset = directionFactor * pick(options.offset, axisOffset[side]); + + lineHeightCorrection = side === 2 ? axis.tickBaseline : 0; + labelOffsetPadded = labelOffset + titleMargin + + (labelOffset && (directionFactor * (horiz ? pick(labelOptions.y, axis.tickBaseline + 8) : labelOptions.x) - lineHeightCorrection)); + axis.axisTitleMargin = pick(titleOffsetOption, labelOffsetPadded); + + axisOffset[side] = mathMax( + axisOffset[side], + axis.axisTitleMargin + titleOffset + directionFactor * axis.offset, + labelOffsetPadded // #3027 + ); + clipOffset[invertedSide] = mathMax(clipOffset[invertedSide], mathFloor(options.lineWidth / 2) * 2); + }, + + /** + * Get the path for the axis line + */ + getLinePath: function (lineWidth) { + var chart = this.chart, + opposite = this.opposite, + offset = this.offset, + horiz = this.horiz, + lineLeft = this.left + (opposite ? this.width : 0) + offset, + lineTop = chart.chartHeight - this.bottom - (opposite ? this.height : 0) + offset; + + if (opposite) { + lineWidth *= -1; // crispify the other way - #1480, #1687 + } + + return chart.renderer.crispLine([ + M, + horiz ? + this.left : + lineLeft, + horiz ? + lineTop : + this.top, + L, + horiz ? + chart.chartWidth - this.right : + lineLeft, + horiz ? + lineTop : + chart.chartHeight - this.bottom + ], lineWidth); + }, + + /** + * Position the title + */ + getTitlePosition: function () { + // compute anchor points for each of the title align options + var horiz = this.horiz, + axisLeft = this.left, + axisTop = this.top, + axisLength = this.len, + axisTitleOptions = this.options.title, + margin = horiz ? axisLeft : axisTop, + opposite = this.opposite, + offset = this.offset, + fontSize = pInt(axisTitleOptions.style.fontSize || 12), + + // the position in the length direction of the axis + alongAxis = { + low: margin + (horiz ? 0 : axisLength), + middle: margin + axisLength / 2, + high: margin + (horiz ? axisLength : 0) + }[axisTitleOptions.align], + + // the position in the perpendicular direction of the axis + offAxis = (horiz ? axisTop + this.height : axisLeft) + + (horiz ? 1 : -1) * // horizontal axis reverses the margin + (opposite ? -1 : 1) * // so does opposite axes + this.axisTitleMargin + + (this.side === 2 ? fontSize : 0); + + return { + x: horiz ? + alongAxis : + offAxis + (opposite ? this.width : 0) + offset + + (axisTitleOptions.x || 0), // x + y: horiz ? + offAxis - (opposite ? this.height : 0) + offset : + alongAxis + (axisTitleOptions.y || 0) // y + }; + }, + + /** + * Render the axis + */ + render: function () { + var axis = this, + horiz = axis.horiz, + reversed = axis.reversed, + chart = axis.chart, + renderer = chart.renderer, + options = axis.options, + isLog = axis.isLog, + isLinked = axis.isLinked, + tickPositions = axis.tickPositions, + sortedPositions, + axisTitle = axis.axisTitle, + ticks = axis.ticks, + minorTicks = axis.minorTicks, + alternateBands = axis.alternateBands, + stackLabelOptions = options.stackLabels, + alternateGridColor = options.alternateGridColor, + tickmarkOffset = axis.tickmarkOffset, + lineWidth = options.lineWidth, + linePath, + hasRendered = chart.hasRendered, + slideInTicks = hasRendered && defined(axis.oldMin) && !isNaN(axis.oldMin), + hasData = axis.hasData, + showAxis = axis.showAxis, + from, + overflow = options.labels.overflow, + justifyLabels = axis.justifyLabels = horiz && overflow !== false, + to; + + // Reset + axis.labelEdge.length = 0; + axis.justifyToPlot = overflow === 'justify'; + + // Mark all elements inActive before we go over and mark the active ones + each([ticks, minorTicks, alternateBands], function (coll) { + var pos; + for (pos in coll) { + coll[pos].isActive = false; + } + }); + + // If the series has data draw the ticks. Else only the line and title + if (hasData || isLinked) { + + // minor ticks + if (axis.minorTickInterval && !axis.categories) { + each(axis.getMinorTickPositions(), function (pos) { + if (!minorTicks[pos]) { + minorTicks[pos] = new Tick(axis, pos, 'minor'); + } + + // render new ticks in old position + if (slideInTicks && minorTicks[pos].isNew) { + minorTicks[pos].render(null, true); + } + + minorTicks[pos].render(null, false, 1); + }); + } + + // Major ticks. Pull out the first item and render it last so that + // we can get the position of the neighbour label. #808. + if (tickPositions.length) { // #1300 + sortedPositions = tickPositions.slice(); + if ((horiz && reversed) || (!horiz && !reversed)) { + sortedPositions.reverse(); + } + if (justifyLabels) { + sortedPositions = sortedPositions.slice(1).concat([sortedPositions[0]]); + } + each(sortedPositions, function (pos, i) { + + // Reorganize the indices + if (justifyLabels) { + i = (i === sortedPositions.length - 1) ? 0 : i + 1; + } + + // linked axes need an extra check to find out if + if (!isLinked || (pos >= axis.min && pos <= axis.max)) { + + if (!ticks[pos]) { + ticks[pos] = new Tick(axis, pos); + } + + // render new ticks in old position + if (slideInTicks && ticks[pos].isNew) { + ticks[pos].render(i, true, 0.1); + } + + ticks[pos].render(i); + } + + }); + // In a categorized axis, the tick marks are displayed between labels. So + // we need to add a tick mark and grid line at the left edge of the X axis. + if (tickmarkOffset && axis.min === 0) { + if (!ticks[-1]) { + ticks[-1] = new Tick(axis, -1, null, true); + } + ticks[-1].render(-1); + } + + } + + // alternate grid color + if (alternateGridColor) { + each(tickPositions, function (pos, i) { + if (i % 2 === 0 && pos < axis.max) { + if (!alternateBands[pos]) { + alternateBands[pos] = new Highcharts.PlotLineOrBand(axis); + } + from = pos + tickmarkOffset; // #949 + to = tickPositions[i + 1] !== UNDEFINED ? tickPositions[i + 1] + tickmarkOffset : axis.max; + alternateBands[pos].options = { + from: isLog ? lin2log(from) : from, + to: isLog ? lin2log(to) : to, + color: alternateGridColor + }; + alternateBands[pos].render(); + alternateBands[pos].isActive = true; + } + }); + } + + // custom plot lines and bands + if (!axis._addedPlotLB) { // only first time + each((options.plotLines || []).concat(options.plotBands || []), function (plotLineOptions) { + axis.addPlotBandOrLine(plotLineOptions); + }); + axis._addedPlotLB = true; + } + + } // end if hasData + + // Remove inactive ticks + each([ticks, minorTicks, alternateBands], function (coll) { + var pos, + i, + forDestruction = [], + delay = globalAnimation ? globalAnimation.duration || 500 : 0, + destroyInactiveItems = function () { + i = forDestruction.length; + while (i--) { + // When resizing rapidly, the same items may be destroyed in different timeouts, + // or the may be reactivated + if (coll[forDestruction[i]] && !coll[forDestruction[i]].isActive) { + coll[forDestruction[i]].destroy(); + delete coll[forDestruction[i]]; + } + } + + }; + + for (pos in coll) { + + if (!coll[pos].isActive) { + // Render to zero opacity + coll[pos].render(pos, false, 0); + coll[pos].isActive = false; + forDestruction.push(pos); + } + } + + // When the objects are finished fading out, destroy them + if (coll === alternateBands || !chart.hasRendered || !delay) { + destroyInactiveItems(); + } else if (delay) { + setTimeout(destroyInactiveItems, delay); + } + }); + + // Static items. As the axis group is cleared on subsequent calls + // to render, these items are added outside the group. + // axis line + if (lineWidth) { + linePath = axis.getLinePath(lineWidth); + if (!axis.axisLine) { + axis.axisLine = renderer.path(linePath) + .attr({ + stroke: options.lineColor, + 'stroke-width': lineWidth, + zIndex: 7 + }) + .add(axis.axisGroup); + } else { + axis.axisLine.animate({ d: linePath }); + } + + // show or hide the line depending on options.showEmpty + axis.axisLine[showAxis ? 'show' : 'hide'](); + } + + if (axisTitle && showAxis) { + + axisTitle[axisTitle.isNew ? 'attr' : 'animate']( + axis.getTitlePosition() + ); + axisTitle.isNew = false; + } + + // Stacked totals: + if (stackLabelOptions && stackLabelOptions.enabled) { + axis.renderStackTotals(); + } + // End stacked totals + + axis.isDirty = false; + }, + + /** + * Redraw the axis to reflect changes in the data or axis extremes + */ + redraw: function () { + + // render the axis + this.render(); + + // move plot lines and bands + each(this.plotLinesAndBands, function (plotLine) { + plotLine.render(); + }); + + // mark associated series as dirty and ready for redraw + each(this.series, function (series) { + series.isDirty = true; + }); + + }, + + /** + * Destroys an Axis instance. + */ + destroy: function (keepEvents) { + var axis = this, + stacks = axis.stacks, + stackKey, + plotLinesAndBands = axis.plotLinesAndBands, + i; + + // Remove the events + if (!keepEvents) { + removeEvent(axis); + } + + // Destroy each stack total + for (stackKey in stacks) { + destroyObjectProperties(stacks[stackKey]); + + stacks[stackKey] = null; + } + + // Destroy collections + each([axis.ticks, axis.minorTicks, axis.alternateBands], function (coll) { + destroyObjectProperties(coll); + }); + i = plotLinesAndBands.length; + while (i--) { // #1975 + plotLinesAndBands[i].destroy(); + } + + // Destroy local variables + each(['stackTotalGroup', 'axisLine', 'axisTitle', 'axisGroup', 'cross', 'gridGroup', 'labelGroup'], function (prop) { + if (axis[prop]) { + axis[prop] = axis[prop].destroy(); + } + }); + + // Destroy crosshair + if (this.cross) { + this.cross.destroy(); + } + }, + + /** + * Draw the crosshair + */ + drawCrosshair: function (e, point) { + if (!this.crosshair) { return; }// Do not draw crosshairs if you don't have too. + + if ((defined(point) || !pick(this.crosshair.snap, true)) === false) { + this.hideCrosshair(); + return; + } + + var path, + options = this.crosshair, + animation = options.animation, + pos; + + // Get the path + if (!pick(options.snap, true)) { + pos = (this.horiz ? e.chartX - this.pos : this.len - e.chartY + this.pos); + } else if (defined(point)) { + /*jslint eqeq: true*/ + pos = (this.chart.inverted != this.horiz) ? point.plotX : this.len - point.plotY; + /*jslint eqeq: false*/ + } + + if (this.isRadial) { + path = this.getPlotLinePath(this.isXAxis ? point.x : pick(point.stackY, point.y)); + } else { + path = this.getPlotLinePath(null, null, null, null, pos); + } + + if (path === null) { + this.hideCrosshair(); + return; + } + + // Draw the cross + if (this.cross) { + this.cross + .attr({ visibility: VISIBLE })[animation ? 'animate' : 'attr']({ d: path }, animation); + } else { + var attribs = { + 'stroke-width': options.width || 1, + stroke: options.color || '#C0C0C0', + zIndex: options.zIndex || 2 + }; + if (options.dashStyle) { + attribs.dashstyle = options.dashStyle; + } + this.cross = this.chart.renderer.path(path).attr(attribs).add(); + } + }, + + /** + * Hide the crosshair. + */ + hideCrosshair: function () { + if (this.cross) { + this.cross.hide(); + } + } +}; // end Axis + +extend(Axis.prototype, AxisPlotLineOrBandExtension); + +/** + * Set the tick positions to a time unit that makes sense, for example + * on the first of each month or on every Monday. Return an array + * with the time positions. Used in datetime axes as well as for grouping + * data on a datetime axis. + * + * @param {Object} normalizedInterval The interval in axis values (ms) and the count + * @param {Number} min The minimum in axis values + * @param {Number} max The maximum in axis values + * @param {Number} startOfWeek + */ +Axis.prototype.getTimeTicks = function (normalizedInterval, min, max, startOfWeek) { + var tickPositions = [], + i, + higherRanks = {}, + useUTC = defaultOptions.global.useUTC, + minYear, // used in months and years as a basis for Date.UTC() + minDate = new Date(min - timezoneOffset), + interval = normalizedInterval.unitRange, + count = normalizedInterval.count; + + if (defined(min)) { // #1300 + if (interval >= timeUnits.second) { // second + minDate.setMilliseconds(0); + minDate.setSeconds(interval >= timeUnits.minute ? 0 : + count * mathFloor(minDate.getSeconds() / count)); + } + + if (interval >= timeUnits.minute) { // minute + minDate[setMinutes](interval >= timeUnits.hour ? 0 : + count * mathFloor(minDate[getMinutes]() / count)); + } + + if (interval >= timeUnits.hour) { // hour + minDate[setHours](interval >= timeUnits.day ? 0 : + count * mathFloor(minDate[getHours]() / count)); + } + + if (interval >= timeUnits.day) { // day + minDate[setDate](interval >= timeUnits.month ? 1 : + count * mathFloor(minDate[getDate]() / count)); + } + + if (interval >= timeUnits.month) { // month + minDate[setMonth](interval >= timeUnits.year ? 0 : + count * mathFloor(minDate[getMonth]() / count)); + minYear = minDate[getFullYear](); + } + + if (interval >= timeUnits.year) { // year + minYear -= minYear % count; + minDate[setFullYear](minYear); + } + + // week is a special case that runs outside the hierarchy + if (interval === timeUnits.week) { + // get start of current week, independent of count + minDate[setDate](minDate[getDate]() - minDate[getDay]() + + pick(startOfWeek, 1)); + } + + + // get tick positions + i = 1; + if (timezoneOffset) { + minDate = new Date(minDate.getTime() + timezoneOffset); + } + minYear = minDate[getFullYear](); + var time = minDate.getTime(), + minMonth = minDate[getMonth](), + minDateDate = minDate[getDate](), + localTimezoneOffset = useUTC ? + timezoneOffset : + (24 * 3600 * 1000 + minDate.getTimezoneOffset() * 60 * 1000) % (24 * 3600 * 1000); // #950 + + // iterate and add tick positions at appropriate values + while (time < max) { + tickPositions.push(time); + + // if the interval is years, use Date.UTC to increase years + if (interval === timeUnits.year) { + time = makeTime(minYear + i * count, 0); + + // if the interval is months, use Date.UTC to increase months + } else if (interval === timeUnits.month) { + time = makeTime(minYear, minMonth + i * count); + + // if we're using global time, the interval is not fixed as it jumps + // one hour at the DST crossover + } else if (!useUTC && (interval === timeUnits.day || interval === timeUnits.week)) { + time = makeTime(minYear, minMonth, minDateDate + + i * count * (interval === timeUnits.day ? 1 : 7)); + + // else, the interval is fixed and we use simple addition + } else { + time += interval * count; + } + + i++; + } + + // push the last time + tickPositions.push(time); + + + // mark new days if the time is dividible by day (#1649, #1760) + each(grep(tickPositions, function (time) { + return interval <= timeUnits.hour && time % timeUnits.day === localTimezoneOffset; + }), function (time) { + higherRanks[time] = 'day'; + }); + } + + + // record information on the chosen unit - for dynamic label formatter + tickPositions.info = extend(normalizedInterval, { + higherRanks: higherRanks, + totalRange: interval * count + }); + + return tickPositions; +}; + +/** + * Get a normalized tick interval for dates. Returns a configuration object with + * unit range (interval), count and name. Used to prepare data for getTimeTicks. + * Previously this logic was part of getTimeTicks, but as getTimeTicks now runs + * of segments in stock charts, the normalizing logic was extracted in order to + * prevent it for running over again for each segment having the same interval. + * #662, #697. + */ +Axis.prototype.normalizeTimeTickInterval = function (tickInterval, unitsOption) { + var units = unitsOption || [[ + 'millisecond', // unit name + [1, 2, 5, 10, 20, 25, 50, 100, 200, 500] // allowed multiples + ], [ + 'second', + [1, 2, 5, 10, 15, 30] + ], [ + 'minute', + [1, 2, 5, 10, 15, 30] + ], [ + 'hour', + [1, 2, 3, 4, 6, 8, 12] + ], [ + 'day', + [1, 2] + ], [ + 'week', + [1, 2] + ], [ + 'month', + [1, 2, 3, 4, 6] + ], [ + 'year', + null + ]], + unit = units[units.length - 1], // default unit is years + interval = timeUnits[unit[0]], + multiples = unit[1], + count, + i; + + // loop through the units to find the one that best fits the tickInterval + for (i = 0; i < units.length; i++) { + unit = units[i]; + interval = timeUnits[unit[0]]; + multiples = unit[1]; + + + if (units[i + 1]) { + // lessThan is in the middle between the highest multiple and the next unit. + var lessThan = (interval * multiples[multiples.length - 1] + + timeUnits[units[i + 1][0]]) / 2; + + // break and keep the current unit + if (tickInterval <= lessThan) { + break; + } + } + } + + // prevent 2.5 years intervals, though 25, 250 etc. are allowed + if (interval === timeUnits.year && tickInterval < 5 * interval) { + multiples = [1, 2, 5]; + } + + // get the count + count = normalizeTickInterval( + tickInterval / interval, + multiples, + unit[0] === 'year' ? mathMax(getMagnitude(tickInterval / interval), 1) : 1 // #1913, #2360 + ); + + return { + unitRange: interval, + count: count, + unitName: unit[0] + }; +};/** + * Methods defined on the Axis prototype + */ + +/** + * Set the tick positions of a logarithmic axis + */ +Axis.prototype.getLogTickPositions = function (interval, min, max, minor) { + var axis = this, + options = axis.options, + axisLength = axis.len, + // Since we use this method for both major and minor ticks, + // use a local variable and return the result + positions = []; + + // Reset + if (!minor) { + axis._minorAutoInterval = null; + } + + // First case: All ticks fall on whole logarithms: 1, 10, 100 etc. + if (interval >= 0.5) { + interval = mathRound(interval); + positions = axis.getLinearTickPositions(interval, min, max); + + // Second case: We need intermediary ticks. For example + // 1, 2, 4, 6, 8, 10, 20, 40 etc. + } else if (interval >= 0.08) { + var roundedMin = mathFloor(min), + intermediate, + i, + j, + len, + pos, + lastPos, + break2; + + if (interval > 0.3) { + intermediate = [1, 2, 4]; + } else if (interval > 0.15) { // 0.2 equals five minor ticks per 1, 10, 100 etc + intermediate = [1, 2, 4, 6, 8]; + } else { // 0.1 equals ten minor ticks per 1, 10, 100 etc + intermediate = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + } + + for (i = roundedMin; i < max + 1 && !break2; i++) { + len = intermediate.length; + for (j = 0; j < len && !break2; j++) { + pos = log2lin(lin2log(i) * intermediate[j]); + if (pos > min && (!minor || lastPos <= max) && lastPos !== UNDEFINED) { // #1670, lastPos is #3113 + positions.push(lastPos); + } + + if (lastPos > max) { + break2 = true; + } + lastPos = pos; + } + } + + // Third case: We are so deep in between whole logarithmic values that + // we might as well handle the tick positions like a linear axis. For + // example 1.01, 1.02, 1.03, 1.04. + } else { + var realMin = lin2log(min), + realMax = lin2log(max), + tickIntervalOption = options[minor ? 'minorTickInterval' : 'tickInterval'], + filteredTickIntervalOption = tickIntervalOption === 'auto' ? null : tickIntervalOption, + tickPixelIntervalOption = options.tickPixelInterval / (minor ? 5 : 1), + totalPixelLength = minor ? axisLength / axis.tickPositions.length : axisLength; + + interval = pick( + filteredTickIntervalOption, + axis._minorAutoInterval, + (realMax - realMin) * tickPixelIntervalOption / (totalPixelLength || 1) + ); + + interval = normalizeTickInterval( + interval, + null, + getMagnitude(interval) + ); + + positions = map(axis.getLinearTickPositions( + interval, + realMin, + realMax + ), log2lin); + + if (!minor) { + axis._minorAutoInterval = interval / 5; + } + } + + // Set the axis-level tickInterval variable + if (!minor) { + axis.tickInterval = interval; + } + return positions; +};/** + * The tooltip object + * @param {Object} chart The chart instance + * @param {Object} options Tooltip options + */ +var Tooltip = Highcharts.Tooltip = function () { + this.init.apply(this, arguments); +}; + +Tooltip.prototype = { + + init: function (chart, options) { + + var borderWidth = options.borderWidth, + style = options.style, + padding = pInt(style.padding); + + // Save the chart and options + this.chart = chart; + this.options = options; + + // Keep track of the current series + //this.currentSeries = UNDEFINED; + + // List of crosshairs + this.crosshairs = []; + + // Current values of x and y when animating + this.now = { x: 0, y: 0 }; + + // The tooltip is initially hidden + this.isHidden = true; + + + // create the label + this.label = chart.renderer.label('', 0, 0, options.shape || 'callout', null, null, options.useHTML, null, 'tooltip') + .attr({ + padding: padding, + fill: options.backgroundColor, + 'stroke-width': borderWidth, + r: options.borderRadius, + zIndex: 8 + }) + .css(style) + .css({ padding: 0 }) // Remove it from VML, the padding is applied as an attribute instead (#1117) + .add() + .attr({ y: -9999 }); // #2301, #2657 + + // When using canVG the shadow shows up as a gray circle + // even if the tooltip is hidden. + if (!useCanVG) { + this.label.shadow(options.shadow); + } + + // Public property for getting the shared state. + this.shared = options.shared; + }, + + /** + * Destroy the tooltip and its elements. + */ + destroy: function () { + // Destroy and clear local variables + if (this.label) { + this.label = this.label.destroy(); + } + clearTimeout(this.hideTimer); + clearTimeout(this.tooltipTimeout); + }, + + /** + * Provide a soft movement for the tooltip + * + * @param {Number} x + * @param {Number} y + * @private + */ + move: function (x, y, anchorX, anchorY) { + var tooltip = this, + now = tooltip.now, + animate = tooltip.options.animation !== false && !tooltip.isHidden && + // When we get close to the target position, abort animation and land on the right place (#3056) + (mathAbs(x - now.x) > 1 || mathAbs(y - now.y) > 1), + skipAnchor = tooltip.followPointer || tooltip.len > 1; + + // Get intermediate values for animation + extend(now, { + x: animate ? (2 * now.x + x) / 3 : x, + y: animate ? (now.y + y) / 2 : y, + anchorX: skipAnchor ? UNDEFINED : animate ? (2 * now.anchorX + anchorX) / 3 : anchorX, + anchorY: skipAnchor ? UNDEFINED : animate ? (now.anchorY + anchorY) / 2 : anchorY + }); + + // Move to the intermediate value + tooltip.label.attr(now); + + + // Run on next tick of the mouse tracker + if (animate) { + + // Never allow two timeouts + clearTimeout(this.tooltipTimeout); + + // Set the fixed interval ticking for the smooth tooltip + this.tooltipTimeout = setTimeout(function () { + // The interval function may still be running during destroy, so check that the chart is really there before calling. + if (tooltip) { + tooltip.move(x, y, anchorX, anchorY); + } + }, 32); + + } + }, + + /** + * Hide the tooltip + */ + hide: function () { + var tooltip = this, + hoverPoints; + + clearTimeout(this.hideTimer); // disallow duplicate timers (#1728, #1766) + if (!this.isHidden) { + hoverPoints = this.chart.hoverPoints; + + this.hideTimer = setTimeout(function () { + tooltip.label.fadeOut(); + tooltip.isHidden = true; + }, pick(this.options.hideDelay, 500)); + + // hide previous hoverPoints and set new + if (hoverPoints) { + each(hoverPoints, function (point) { + point.setState(); + }); + } + + this.chart.hoverPoints = null; + } + }, + + /** + * Extendable method to get the anchor position of the tooltip + * from a point or set of points + */ + getAnchor: function (points, mouseEvent) { + var ret, + chart = this.chart, + inverted = chart.inverted, + plotTop = chart.plotTop, + plotX = 0, + plotY = 0, + yAxis; + + points = splat(points); + + // Pie uses a special tooltipPos + ret = points[0].tooltipPos; + + // When tooltip follows mouse, relate the position to the mouse + if (this.followPointer && mouseEvent) { + if (mouseEvent.chartX === UNDEFINED) { + mouseEvent = chart.pointer.normalize(mouseEvent); + } + ret = [ + mouseEvent.chartX - chart.plotLeft, + mouseEvent.chartY - plotTop + ]; + } + // When shared, use the average position + if (!ret) { + each(points, function (point) { + yAxis = point.series.yAxis; + plotX += point.plotX; + plotY += (point.plotLow ? (point.plotLow + point.plotHigh) / 2 : point.plotY) + + (!inverted && yAxis ? yAxis.top - plotTop : 0); // #1151 + }); + + plotX /= points.length; + plotY /= points.length; + + ret = [ + inverted ? chart.plotWidth - plotY : plotX, + this.shared && !inverted && points.length > 1 && mouseEvent ? + mouseEvent.chartY - plotTop : // place shared tooltip next to the mouse (#424) + inverted ? chart.plotHeight - plotX : plotY + ]; + } + + return map(ret, mathRound); + }, + + /** + * Place the tooltip in a chart without spilling over + * and not covering the point it self. + */ + getPosition: function (boxWidth, boxHeight, point) { + + var chart = this.chart, + distance = this.distance, + ret = {}, + swapped, + first = ['y', chart.chartHeight, boxHeight, point.plotY + chart.plotTop], + second = ['x', chart.chartWidth, boxWidth, point.plotX + chart.plotLeft], + // The far side is right or bottom + preferFarSide = point.ttBelow || (chart.inverted && !point.negative) || (!chart.inverted && point.negative), + /** + * Handle the preferred dimension. When the preferred dimension is tooltip + * on top or bottom of the point, it will look for space there. + */ + firstDimension = function (dim, outerSize, innerSize, point) { + var roomLeft = innerSize < point - distance, + roomRight = point + distance + innerSize < outerSize, + alignedLeft = point - distance - innerSize, + alignedRight = point + distance; + + if (preferFarSide && roomRight) { + ret[dim] = alignedRight; + } else if (!preferFarSide && roomLeft) { + ret[dim] = alignedLeft; + } else if (roomLeft) { + ret[dim] = alignedLeft; + } else if (roomRight) { + ret[dim] = alignedRight; + } else { + return false; + } + }, + /** + * Handle the secondary dimension. If the preferred dimension is tooltip + * on top or bottom of the point, the second dimension is to align the tooltip + * above the point, trying to align center but allowing left or right + * align within the chart box. + */ + secondDimension = function (dim, outerSize, innerSize, point) { + // Too close to the edge, return false and swap dimensions + if (point < distance || point > outerSize - distance) { + return false; + + // Align left/top + } else if (point < innerSize / 2) { + ret[dim] = 1; + // Align right/bottom + } else if (point > outerSize - innerSize / 2) { + ret[dim] = outerSize - innerSize - 2; + // Align center + } else { + ret[dim] = point - innerSize / 2; + } + }, + /** + * Swap the dimensions + */ + swap = function (count) { + var temp = first; + first = second; + second = temp; + swapped = count; + }, + run = function () { + if (firstDimension.apply(0, first) !== false) { + if (secondDimension.apply(0, second) === false && !swapped) { + swap(true); + run(); + } + } else if (!swapped) { + swap(true); + run(); + } else { + ret.x = ret.y = 0; + } + }; + + // Under these conditions, prefer the tooltip on the side of the point + if (chart.inverted || this.len > 1) { + swap(); + } + run(); + + return ret; + + }, + + /** + * In case no user defined formatter is given, this will be used. Note that the context + * here is an object holding point, series, x, y etc. + */ + defaultFormatter: function (tooltip) { + var items = this.points || splat(this), + series = items[0].series, + s; + + // build the header + s = [tooltip.tooltipHeaderFormatter(items[0])]; + + // build the values + each(items, function (item) { + series = item.series; + s.push((series.tooltipFormatter && series.tooltipFormatter(item)) || + item.point.tooltipFormatter(series.tooltipOptions.pointFormat)); + }); + + // footer + s.push(tooltip.options.footerFormat || ''); + + return s.join(''); + }, + + /** + * Refresh the tooltip's text and position. + * @param {Object} point + */ + refresh: function (point, mouseEvent) { + var tooltip = this, + chart = tooltip.chart, + label = tooltip.label, + options = tooltip.options, + x, + y, + anchor, + textConfig = {}, + text, + pointConfig = [], + formatter = options.formatter || tooltip.defaultFormatter, + hoverPoints = chart.hoverPoints, + borderColor, + shared = tooltip.shared, + currentSeries; + + clearTimeout(this.hideTimer); + + // get the reference point coordinates (pie charts use tooltipPos) + tooltip.followPointer = splat(point)[0].series.tooltipOptions.followPointer; + anchor = tooltip.getAnchor(point, mouseEvent); + x = anchor[0]; + y = anchor[1]; + + // shared tooltip, array is sent over + if (shared && !(point.series && point.series.noSharedTooltip)) { + + // hide previous hoverPoints and set new + + chart.hoverPoints = point; + if (hoverPoints) { + each(hoverPoints, function (point) { + point.setState(); + }); + } + + each(point, function (item) { + item.setState(HOVER_STATE); + + pointConfig.push(item.getLabelConfig()); + }); + + textConfig = { + x: point[0].category, + y: point[0].y + }; + textConfig.points = pointConfig; + this.len = pointConfig.length; + point = point[0]; + + // single point tooltip + } else { + textConfig = point.getLabelConfig(); + } + text = formatter.call(textConfig, tooltip); + + // register the current series + currentSeries = point.series; + this.distance = pick(currentSeries.tooltipOptions.distance, 16); + + // update the inner HTML + if (text === false) { + this.hide(); + } else { + + // show it + if (tooltip.isHidden) { + stop(label); + label.attr('opacity', 1).show(); + } + + // update text + label.attr({ + text: text + }); + + // set the stroke color of the box + borderColor = options.borderColor || point.color || currentSeries.color || '#606060'; + label.attr({ + stroke: borderColor + }); + + tooltip.updatePosition({ plotX: x, plotY: y, negative: point.negative, ttBelow: point.ttBelow }); + + this.isHidden = false; + } + fireEvent(chart, 'tooltipRefresh', { + text: text, + x: x + chart.plotLeft, + y: y + chart.plotTop, + borderColor: borderColor + }); + }, + + /** + * Find the new position and perform the move + */ + updatePosition: function (point) { + var chart = this.chart, + label = this.label, + pos = (this.options.positioner || this.getPosition).call( + this, + label.width, + label.height, + point + ); + + // do the move + this.move( + mathRound(pos.x), + mathRound(pos.y), + point.plotX + chart.plotLeft, + point.plotY + chart.plotTop + ); + }, + + + /** + * Format the header of the tooltip + */ + tooltipHeaderFormatter: function (point) { + var series = point.series, + tooltipOptions = series.tooltipOptions, + dateTimeLabelFormats = tooltipOptions.dateTimeLabelFormats, + xDateFormat = tooltipOptions.xDateFormat, + xAxis = series.xAxis, + isDateTime = xAxis && xAxis.options.type === 'datetime' && isNumber(point.key), + headerFormat = tooltipOptions.headerFormat, + closestPointRange = xAxis && xAxis.closestPointRange, + n; + + // Guess the best date format based on the closest point distance (#568) + if (isDateTime && !xDateFormat) { + if (closestPointRange) { + for (n in timeUnits) { + if (timeUnits[n] >= closestPointRange || + // If the point is placed every day at 23:59, we need to show + // the minutes as well. This logic only works for time units less than + // a day, since all higher time units are dividable by those. #2637. + (timeUnits[n] <= timeUnits.day && point.key % timeUnits[n] > 0)) { + xDateFormat = dateTimeLabelFormats[n]; + break; + } + } + } else { + xDateFormat = dateTimeLabelFormats.day; + } + + xDateFormat = xDateFormat || dateTimeLabelFormats.year; // #2546, 2581 + + } + + // Insert the header date format if any + if (isDateTime && xDateFormat) { + headerFormat = headerFormat.replace('{point.key}', '{point.key:' + xDateFormat + '}'); + } + + return format(headerFormat, { + point: point, + series: series + }); + } +}; + +var hoverChartIndex; + +// Global flag for touch support +hasTouch = doc.documentElement.ontouchstart !== UNDEFINED; + +/** + * The mouse tracker object. All methods starting with "on" are primary DOM event handlers. + * Subsequent methods should be named differently from what they are doing. + * @param {Object} chart The Chart instance + * @param {Object} options The root options object + */ +var Pointer = Highcharts.Pointer = function (chart, options) { + this.init(chart, options); +}; + +Pointer.prototype = { + /** + * Initialize Pointer + */ + init: function (chart, options) { + + var chartOptions = options.chart, + chartEvents = chartOptions.events, + zoomType = useCanVG ? '' : chartOptions.zoomType, + inverted = chart.inverted, + zoomX, + zoomY; + + // Store references + this.options = options; + this.chart = chart; + + // Zoom status + this.zoomX = zoomX = /x/.test(zoomType); + this.zoomY = zoomY = /y/.test(zoomType); + this.zoomHor = (zoomX && !inverted) || (zoomY && inverted); + this.zoomVert = (zoomY && !inverted) || (zoomX && inverted); + this.hasZoom = zoomX || zoomY; + + // Do we need to handle click on a touch device? + this.runChartClick = chartEvents && !!chartEvents.click; + + this.pinchDown = []; + this.lastValidTouch = {}; + + if (Highcharts.Tooltip && options.tooltip.enabled) { + chart.tooltip = new Tooltip(chart, options.tooltip); + this.followTouchMove = options.tooltip.followTouchMove; + } + + this.setDOMEvents(); + }, + + /** + * Add crossbrowser support for chartX and chartY + * @param {Object} e The event object in standard browsers + */ + normalize: function (e, chartPosition) { + var chartX, + chartY, + ePos; + + // common IE normalizing + e = e || window.event; + + // Framework specific normalizing (#1165) + e = washMouseEvent(e); + + // More IE normalizing, needs to go after washMouseEvent + if (!e.target) { + e.target = e.srcElement; + } + + // iOS (#2757) + ePos = e.touches ? (e.touches.length ? e.touches.item(0) : e.changedTouches[0]) : e; + + // Get mouse position + if (!chartPosition) { + this.chartPosition = chartPosition = offset(this.chart.container); + } + + // chartX and chartY + if (ePos.pageX === UNDEFINED) { // IE < 9. #886. + chartX = mathMax(e.x, e.clientX - chartPosition.left); // #2005, #2129: the second case is + // for IE10 quirks mode within framesets + chartY = e.y; + } else { + chartX = ePos.pageX - chartPosition.left; + chartY = ePos.pageY - chartPosition.top; + } + + return extend(e, { + chartX: mathRound(chartX), + chartY: mathRound(chartY) + }); + }, + + /** + * Get the click position in terms of axis values. + * + * @param {Object} e A pointer event + */ + getCoordinates: function (e) { + var coordinates = { + xAxis: [], + yAxis: [] + }; + + each(this.chart.axes, function (axis) { + coordinates[axis.isXAxis ? 'xAxis' : 'yAxis'].push({ + axis: axis, + value: axis.toValue(e[axis.horiz ? 'chartX' : 'chartY']) + }); + }); + return coordinates; + }, + + /** + * Return the index in the tooltipPoints array, corresponding to pixel position in + * the plot area. + */ + getIndex: function (e) { + var chart = this.chart; + return chart.inverted ? + chart.plotHeight + chart.plotTop - e.chartY : + e.chartX - chart.plotLeft; + }, + + /** + * With line type charts with a single tracker, get the point closest to the mouse. + * Run Point.onMouseOver and display tooltip for the point or points. + */ + runPointActions: function (e) { + var pointer = this, + chart = pointer.chart, + series = chart.series, + tooltip = chart.tooltip, + followPointer, + point, + points, + hoverPoint = chart.hoverPoint, + hoverSeries = chart.hoverSeries, + i, + j, + distance = chart.chartWidth, + index = pointer.getIndex(e), + anchor; + + // shared tooltip + if (tooltip && pointer.options.tooltip.shared && !(hoverSeries && hoverSeries.noSharedTooltip)) { + points = []; + + // loop over all series and find the ones with points closest to the mouse + i = series.length; + for (j = 0; j < i; j++) { + if (series[j].visible && + series[j].options.enableMouseTracking !== false && + !series[j].noSharedTooltip && series[j].singularTooltips !== true && series[j].tooltipPoints.length) { + point = series[j].tooltipPoints[index]; + if (point && point.series) { // not a dummy point, #1544 + point._dist = mathAbs(index - point.clientX); + distance = mathMin(distance, point._dist); + points.push(point); + } + } + } + // remove furthest points + i = points.length; + while (i--) { + if (points[i]._dist > distance) { + points.splice(i, 1); + } + } + // refresh the tooltip if necessary + if (points.length && (points[0].clientX !== pointer.hoverX)) { + tooltip.refresh(points, e); + pointer.hoverX = points[0].clientX; + } + } + + // Separate tooltip and general mouse events + followPointer = hoverSeries && hoverSeries.tooltipOptions.followPointer; + if (hoverSeries && hoverSeries.tracker && !followPointer) { // #2584, #2830 + + // get the point + point = hoverSeries.tooltipPoints[index]; + + // a new point is hovered, refresh the tooltip + if (point && point !== hoverPoint) { + + // trigger the events + point.onMouseOver(e); + + } + + } else if (tooltip && followPointer && !tooltip.isHidden) { + anchor = tooltip.getAnchor([{}], e); + tooltip.updatePosition({ plotX: anchor[0], plotY: anchor[1] }); + } + + // Start the event listener to pick up the tooltip + if (tooltip && !pointer._onDocumentMouseMove) { + pointer._onDocumentMouseMove = function (e) { + if (charts[hoverChartIndex]) { + charts[hoverChartIndex].pointer.onDocumentMouseMove(e); + } + }; + addEvent(doc, 'mousemove', pointer._onDocumentMouseMove); + } + + // Draw independent crosshairs + each(chart.axes, function (axis) { + axis.drawCrosshair(e, pick(point, hoverPoint)); + }); + }, + + + + /** + * Reset the tracking by hiding the tooltip, the hover series state and the hover point + * + * @param allowMove {Boolean} Instead of destroying the tooltip altogether, allow moving it if possible + */ + reset: function (allowMove) { + var pointer = this, + chart = pointer.chart, + hoverSeries = chart.hoverSeries, + hoverPoint = chart.hoverPoint, + tooltip = chart.tooltip, + tooltipPoints = tooltip && tooltip.shared ? chart.hoverPoints : hoverPoint; + + // Narrow in allowMove + allowMove = allowMove && tooltip && tooltipPoints; + + // Check if the points have moved outside the plot area, #1003 + if (allowMove && splat(tooltipPoints)[0].plotX === UNDEFINED) { + allowMove = false; + } + + // Just move the tooltip, #349 + if (allowMove) { + tooltip.refresh(tooltipPoints); + if (hoverPoint) { // #2500 + hoverPoint.setState(hoverPoint.state, true); + } + + // Full reset + } else { + + if (hoverPoint) { + hoverPoint.onMouseOut(); + } + + if (hoverSeries) { + hoverSeries.onMouseOut(); + } + + if (tooltip) { + tooltip.hide(); + } + + if (pointer._onDocumentMouseMove) { + removeEvent(doc, 'mousemove', pointer._onDocumentMouseMove); + pointer._onDocumentMouseMove = null; + } + + // Remove crosshairs + each(chart.axes, function (axis) { + axis.hideCrosshair(); + }); + + pointer.hoverX = null; + + } + }, + + /** + * Scale series groups to a certain scale and translation + */ + scaleGroups: function (attribs, clip) { + + var chart = this.chart, + seriesAttribs; + + // Scale each series + each(chart.series, function (series) { + seriesAttribs = attribs || series.getPlotBox(); // #1701 + if (series.xAxis && series.xAxis.zoomEnabled) { + series.group.attr(seriesAttribs); + if (series.markerGroup) { + series.markerGroup.attr(seriesAttribs); + series.markerGroup.clip(clip ? chart.clipRect : null); + } + if (series.dataLabelsGroup) { + series.dataLabelsGroup.attr(seriesAttribs); + } + } + }); + + // Clip + chart.clipRect.attr(clip || chart.clipBox); + }, + + /** + * Start a drag operation + */ + dragStart: function (e) { + var chart = this.chart; + + // Record the start position + chart.mouseIsDown = e.type; + chart.cancelClick = false; + chart.mouseDownX = this.mouseDownX = e.chartX; + chart.mouseDownY = this.mouseDownY = e.chartY; + }, + + /** + * Perform a drag operation in response to a mousemove event while the mouse is down + */ + drag: function (e) { + + var chart = this.chart, + chartOptions = chart.options.chart, + chartX = e.chartX, + chartY = e.chartY, + zoomHor = this.zoomHor, + zoomVert = this.zoomVert, + plotLeft = chart.plotLeft, + plotTop = chart.plotTop, + plotWidth = chart.plotWidth, + plotHeight = chart.plotHeight, + clickedInside, + size, + mouseDownX = this.mouseDownX, + mouseDownY = this.mouseDownY, + panKey = chartOptions.panKey && e[chartOptions.panKey + 'Key']; + + // If the mouse is outside the plot area, adjust to cooordinates + // inside to prevent the selection marker from going outside + if (chartX < plotLeft) { + chartX = plotLeft; + } else if (chartX > plotLeft + plotWidth) { + chartX = plotLeft + plotWidth; + } + + if (chartY < plotTop) { + chartY = plotTop; + } else if (chartY > plotTop + plotHeight) { + chartY = plotTop + plotHeight; + } + + // determine if the mouse has moved more than 10px + this.hasDragged = Math.sqrt( + Math.pow(mouseDownX - chartX, 2) + + Math.pow(mouseDownY - chartY, 2) + ); + + if (this.hasDragged > 10) { + clickedInside = chart.isInsidePlot(mouseDownX - plotLeft, mouseDownY - plotTop); + + // make a selection + if (chart.hasCartesianSeries && (this.zoomX || this.zoomY) && clickedInside && !panKey) { + if (!this.selectionMarker) { + this.selectionMarker = chart.renderer.rect( + plotLeft, + plotTop, + zoomHor ? 1 : plotWidth, + zoomVert ? 1 : plotHeight, + 0 + ) + .attr({ + fill: chartOptions.selectionMarkerFill || 'rgba(69,114,167,0.25)', + zIndex: 7 + }) + .add(); + } + } + + // adjust the width of the selection marker + if (this.selectionMarker && zoomHor) { + size = chartX - mouseDownX; + this.selectionMarker.attr({ + width: mathAbs(size), + x: (size > 0 ? 0 : size) + mouseDownX + }); + } + // adjust the height of the selection marker + if (this.selectionMarker && zoomVert) { + size = chartY - mouseDownY; + this.selectionMarker.attr({ + height: mathAbs(size), + y: (size > 0 ? 0 : size) + mouseDownY + }); + } + + // panning + if (clickedInside && !this.selectionMarker && chartOptions.panning) { + chart.pan(e, chartOptions.panning); + } + } + }, + + /** + * On mouse up or touch end across the entire document, drop the selection. + */ + drop: function (e) { + var chart = this.chart, + hasPinched = this.hasPinched; + + if (this.selectionMarker) { + var selectionData = { + xAxis: [], + yAxis: [], + originalEvent: e.originalEvent || e + }, + selectionBox = this.selectionMarker, + selectionLeft = selectionBox.attr ? selectionBox.attr('x') : selectionBox.x, + selectionTop = selectionBox.attr ? selectionBox.attr('y') : selectionBox.y, + selectionWidth = selectionBox.attr ? selectionBox.attr('width') : selectionBox.width, + selectionHeight = selectionBox.attr ? selectionBox.attr('height') : selectionBox.height, + runZoom; + + // a selection has been made + if (this.hasDragged || hasPinched) { + + // record each axis' min and max + each(chart.axes, function (axis) { + if (axis.zoomEnabled) { + var horiz = axis.horiz, + minPixelPadding = e.type === 'touchend' ? axis.minPixelPadding: 0, // #1207, #3075 + selectionMin = axis.toValue((horiz ? selectionLeft : selectionTop) + minPixelPadding), + selectionMax = axis.toValue((horiz ? selectionLeft + selectionWidth : selectionTop + selectionHeight) - minPixelPadding); + + if (!isNaN(selectionMin) && !isNaN(selectionMax)) { // #859 + selectionData[axis.coll].push({ + axis: axis, + min: mathMin(selectionMin, selectionMax), // for reversed axes, + max: mathMax(selectionMin, selectionMax) + }); + runZoom = true; + } + } + }); + if (runZoom) { + fireEvent(chart, 'selection', selectionData, function (args) { + chart.zoom(extend(args, hasPinched ? { animation: false } : null)); + }); + } + + } + this.selectionMarker = this.selectionMarker.destroy(); + + // Reset scaling preview + if (hasPinched) { + this.scaleGroups(); + } + } + + // Reset all + if (chart) { // it may be destroyed on mouse up - #877 + css(chart.container, { cursor: chart._cursor }); + chart.cancelClick = this.hasDragged > 10; // #370 + chart.mouseIsDown = this.hasDragged = this.hasPinched = false; + this.pinchDown = []; + } + }, + + onContainerMouseDown: function (e) { + + e = this.normalize(e); + + // issue #295, dragging not always working in Firefox + if (e.preventDefault) { + e.preventDefault(); + } + + this.dragStart(e); + }, + + + + onDocumentMouseUp: function (e) { + if (charts[hoverChartIndex]) { + charts[hoverChartIndex].pointer.drop(e); + } + }, + + /** + * Special handler for mouse move that will hide the tooltip when the mouse leaves the plotarea. + * Issue #149 workaround. The mouseleave event does not always fire. + */ + onDocumentMouseMove: function (e) { + var chart = this.chart, + chartPosition = this.chartPosition, + hoverSeries = chart.hoverSeries; + + e = this.normalize(e, chartPosition); + + // If we're outside, hide the tooltip + if (chartPosition && hoverSeries && !this.inClass(e.target, 'highcharts-tracker') && + !chart.isInsidePlot(e.chartX - chart.plotLeft, e.chartY - chart.plotTop)) { + this.reset(); + } + }, + + /** + * When mouse leaves the container, hide the tooltip. + */ + onContainerMouseLeave: function () { + var chart = charts[hoverChartIndex]; + if (chart) { + chart.pointer.reset(); + chart.pointer.chartPosition = null; // also reset the chart position, used in #149 fix + } + }, + + // The mousemove, touchmove and touchstart event handler + onContainerMouseMove: function (e) { + + var chart = this.chart; + + hoverChartIndex = chart.index; + + e = this.normalize(e); + e.returnValue = false; // #2251, #3224 + + if (chart.mouseIsDown === 'mousedown') { + this.drag(e); + } + + // Show the tooltip and run mouse over events (#977) + if ((this.inClass(e.target, 'highcharts-tracker') || + chart.isInsidePlot(e.chartX - chart.plotLeft, e.chartY - chart.plotTop)) && !chart.openMenu) { + this.runPointActions(e); + } + }, + + /** + * Utility to detect whether an element has, or has a parent with, a specific + * class name. Used on detection of tracker objects and on deciding whether + * hovering the tooltip should cause the active series to mouse out. + */ + inClass: function (element, className) { + var elemClassName; + while (element) { + elemClassName = attr(element, 'class'); + if (elemClassName) { + if (elemClassName.indexOf(className) !== -1) { + return true; + } else if (elemClassName.indexOf(PREFIX + 'container') !== -1) { + return false; + } + } + element = element.parentNode; + } + }, + + onTrackerMouseOut: function (e) { + var series = this.chart.hoverSeries, + relatedTarget = e.relatedTarget || e.toElement, + relatedSeries = relatedTarget && relatedTarget.point && relatedTarget.point.series; // #2499 + + if (series && !series.options.stickyTracking && !this.inClass(relatedTarget, PREFIX + 'tooltip') && + relatedSeries !== series) { + series.onMouseOut(); + } + }, + + onContainerClick: function (e) { + var chart = this.chart, + hoverPoint = chart.hoverPoint, + plotLeft = chart.plotLeft, + plotTop = chart.plotTop; + + e = this.normalize(e); + e.cancelBubble = true; // IE specific + + if (!chart.cancelClick) { + + // On tracker click, fire the series and point events. #783, #1583 + if (hoverPoint && this.inClass(e.target, PREFIX + 'tracker')) { + + // the series click event + fireEvent(hoverPoint.series, 'click', extend(e, { + point: hoverPoint + })); + + // the point click event + if (chart.hoverPoint) { // it may be destroyed (#1844) + hoverPoint.firePointEvent('click', e); + } + + // When clicking outside a tracker, fire a chart event + } else { + extend(e, this.getCoordinates(e)); + + // fire a click event in the chart + if (chart.isInsidePlot(e.chartX - plotLeft, e.chartY - plotTop)) { + fireEvent(chart, 'click', e); + } + } + + + } + }, + + /** + * Set the JS DOM events on the container and document. This method should contain + * a one-to-one assignment between methods and their handlers. Any advanced logic should + * be moved to the handler reflecting the event's name. + */ + setDOMEvents: function () { + + var pointer = this, + container = pointer.chart.container; + + container.onmousedown = function (e) { + pointer.onContainerMouseDown(e); + }; + container.onmousemove = function (e) { + pointer.onContainerMouseMove(e); + }; + container.onclick = function (e) { + pointer.onContainerClick(e); + }; + addEvent(container, 'mouseleave', pointer.onContainerMouseLeave); + if (chartCount === 1) { + addEvent(doc, 'mouseup', pointer.onDocumentMouseUp); + } + if (hasTouch) { + container.ontouchstart = function (e) { + pointer.onContainerTouchStart(e); + }; + container.ontouchmove = function (e) { + pointer.onContainerTouchMove(e); + }; + if (chartCount === 1) { + addEvent(doc, 'touchend', pointer.onDocumentTouchEnd); + } + } + + }, + + /** + * Destroys the Pointer object and disconnects DOM events. + */ + destroy: function () { + var prop; + + removeEvent(this.chart.container, 'mouseleave', this.onContainerMouseLeave); + if (!chartCount) { + removeEvent(doc, 'mouseup', this.onDocumentMouseUp); + removeEvent(doc, 'touchend', this.onDocumentTouchEnd); + } + + // memory and CPU leak + clearInterval(this.tooltipTimeout); + + for (prop in this) { + this[prop] = null; + } + } +}; + + +/* Support for touch devices */ +extend(Highcharts.Pointer.prototype, { + + /** + * Run translation operations + */ + pinchTranslate: function (pinchDown, touches, transform, selectionMarker, clip, lastValidTouch) { + if (this.zoomHor || this.pinchHor) { + this.pinchTranslateDirection(true, pinchDown, touches, transform, selectionMarker, clip, lastValidTouch); + } + if (this.zoomVert || this.pinchVert) { + this.pinchTranslateDirection(false, pinchDown, touches, transform, selectionMarker, clip, lastValidTouch); + } + }, + + /** + * Run translation operations for each direction (horizontal and vertical) independently + */ + pinchTranslateDirection: function (horiz, pinchDown, touches, transform, selectionMarker, clip, lastValidTouch, forcedScale) { + var chart = this.chart, + xy = horiz ? 'x' : 'y', + XY = horiz ? 'X' : 'Y', + sChartXY = 'chart' + XY, + wh = horiz ? 'width' : 'height', + plotLeftTop = chart['plot' + (horiz ? 'Left' : 'Top')], + selectionWH, + selectionXY, + clipXY, + scale = forcedScale || 1, + inverted = chart.inverted, + bounds = chart.bounds[horiz ? 'h' : 'v'], + singleTouch = pinchDown.length === 1, + touch0Start = pinchDown[0][sChartXY], + touch0Now = touches[0][sChartXY], + touch1Start = !singleTouch && pinchDown[1][sChartXY], + touch1Now = !singleTouch && touches[1][sChartXY], + outOfBounds, + transformScale, + scaleKey, + setScale = function () { + if (!singleTouch && mathAbs(touch0Start - touch1Start) > 20) { // Don't zoom if fingers are too close on this axis + scale = forcedScale || mathAbs(touch0Now - touch1Now) / mathAbs(touch0Start - touch1Start); + } + + clipXY = ((plotLeftTop - touch0Now) / scale) + touch0Start; + selectionWH = chart['plot' + (horiz ? 'Width' : 'Height')] / scale; + }; + + // Set the scale, first pass + setScale(); + + selectionXY = clipXY; // the clip position (x or y) is altered if out of bounds, the selection position is not + + // Out of bounds + if (selectionXY < bounds.min) { + selectionXY = bounds.min; + outOfBounds = true; + } else if (selectionXY + selectionWH > bounds.max) { + selectionXY = bounds.max - selectionWH; + outOfBounds = true; + } + + // Is the chart dragged off its bounds, determined by dataMin and dataMax? + if (outOfBounds) { + + // Modify the touchNow position in order to create an elastic drag movement. This indicates + // to the user that the chart is responsive but can't be dragged further. + touch0Now -= 0.8 * (touch0Now - lastValidTouch[xy][0]); + if (!singleTouch) { + touch1Now -= 0.8 * (touch1Now - lastValidTouch[xy][1]); + } + + // Set the scale, second pass to adapt to the modified touchNow positions + setScale(); + + } else { + lastValidTouch[xy] = [touch0Now, touch1Now]; + } + + // Set geometry for clipping, selection and transformation + if (!inverted) { // TODO: implement clipping for inverted charts + clip[xy] = clipXY - plotLeftTop; + clip[wh] = selectionWH; + } + scaleKey = inverted ? (horiz ? 'scaleY' : 'scaleX') : 'scale' + XY; + transformScale = inverted ? 1 / scale : scale; + + selectionMarker[wh] = selectionWH; + selectionMarker[xy] = selectionXY; + transform[scaleKey] = scale; + transform['translate' + XY] = (transformScale * plotLeftTop) + (touch0Now - (transformScale * touch0Start)); + }, + + /** + * Handle touch events with two touches + */ + pinch: function (e) { + + var self = this, + chart = self.chart, + pinchDown = self.pinchDown, + followTouchMove = self.followTouchMove, + touches = e.touches, + touchesLength = touches.length, + lastValidTouch = self.lastValidTouch, + hasZoom = self.hasZoom, + selectionMarker = self.selectionMarker, + transform = {}, + fireClickEvent = touchesLength === 1 && ((self.inClass(e.target, PREFIX + 'tracker') && + chart.runTrackerClick) || chart.runChartClick), + clip = {}; + + // On touch devices, only proceed to trigger click if a handler is defined + if ((hasZoom || followTouchMove) && !fireClickEvent) { + e.preventDefault(); + } + + // Normalize each touch + map(touches, function (e) { + return self.normalize(e); + }); + + // Register the touch start position + if (e.type === 'touchstart') { + each(touches, function (e, i) { + pinchDown[i] = { chartX: e.chartX, chartY: e.chartY }; + }); + lastValidTouch.x = [pinchDown[0].chartX, pinchDown[1] && pinchDown[1].chartX]; + lastValidTouch.y = [pinchDown[0].chartY, pinchDown[1] && pinchDown[1].chartY]; + + // Identify the data bounds in pixels + each(chart.axes, function (axis) { + if (axis.zoomEnabled) { + var bounds = chart.bounds[axis.horiz ? 'h' : 'v'], + minPixelPadding = axis.minPixelPadding, + min = axis.toPixels(pick(axis.options.min, axis.dataMin)), + max = axis.toPixels(pick(axis.options.max, axis.dataMax)), + absMin = mathMin(min, max), + absMax = mathMax(min, max); + + // Store the bounds for use in the touchmove handler + bounds.min = mathMin(axis.pos, absMin - minPixelPadding); + bounds.max = mathMax(axis.pos + axis.len, absMax + minPixelPadding); + } + }); + + // Event type is touchmove, handle panning and pinching + } else if (pinchDown.length) { // can be 0 when releasing, if touchend fires first + + + // Set the marker + if (!selectionMarker) { + self.selectionMarker = selectionMarker = extend({ + destroy: noop + }, chart.plotBox); + } + + self.pinchTranslate(pinchDown, touches, transform, selectionMarker, clip, lastValidTouch); + + self.hasPinched = hasZoom; + + // Scale and translate the groups to provide visual feedback during pinching + self.scaleGroups(transform, clip); + + // Optionally move the tooltip on touchmove + if (!hasZoom && followTouchMove && touchesLength === 1) { + this.runPointActions(self.normalize(e)); + } + } + }, + + onContainerTouchStart: function (e) { + var chart = this.chart; + + hoverChartIndex = chart.index; + + if (e.touches.length === 1) { + + e = this.normalize(e); + + if (chart.isInsidePlot(e.chartX - chart.plotLeft, e.chartY - chart.plotTop)) { + + // Run mouse events and display tooltip etc + this.runPointActions(e); + + this.pinch(e); + + } else { + // Hide the tooltip on touching outside the plot area (#1203) + this.reset(); + } + + } else if (e.touches.length === 2) { + this.pinch(e); + } + }, + + onContainerTouchMove: function (e) { + if (e.touches.length === 1 || e.touches.length === 2) { + this.pinch(e); + } + }, + + onDocumentTouchEnd: function (e) { + if (charts[hoverChartIndex]) { + charts[hoverChartIndex].pointer.drop(e); + } + } + +}); +if (win.PointerEvent || win.MSPointerEvent) { + + // The touches object keeps track of the points being touched at all times + var touches = {}, + hasPointerEvent = !!win.PointerEvent, + getWebkitTouches = function () { + var key, fake = []; + fake.item = function (i) { return this[i]; }; + for (key in touches) { + if (touches.hasOwnProperty(key)) { + fake.push({ + pageX: touches[key].pageX, + pageY: touches[key].pageY, + target: touches[key].target + }); + } + } + return fake; + }, + translateMSPointer = function (e, method, wktype, callback) { + var p; + e = e.originalEvent || e; + if ((e.pointerType === 'touch' || e.pointerType === e.MSPOINTER_TYPE_TOUCH) && charts[hoverChartIndex]) { + callback(e); + p = charts[hoverChartIndex].pointer; + p[method]({ + type: wktype, + target: e.currentTarget, + preventDefault: noop, + touches: getWebkitTouches() + }); + } + }; + + /** + * Extend the Pointer prototype with methods for each event handler and more + */ + extend(Pointer.prototype, { + onContainerPointerDown: function (e) { + translateMSPointer(e, 'onContainerTouchStart', 'touchstart', function (e) { + touches[e.pointerId] = { pageX: e.pageX, pageY: e.pageY, target: e.currentTarget }; + }); + }, + onContainerPointerMove: function (e) { + translateMSPointer(e, 'onContainerTouchMove', 'touchmove', function (e) { + touches[e.pointerId] = { pageX: e.pageX, pageY: e.pageY }; + if (!touches[e.pointerId].target) { + touches[e.pointerId].target = e.currentTarget; + } + }); + }, + onDocumentPointerUp: function (e) { + translateMSPointer(e, 'onContainerTouchEnd', 'touchend', function (e) { + delete touches[e.pointerId]; + }); + }, + + /** + * Add or remove the MS Pointer specific events + */ + batchMSEvents: function (fn) { + fn(this.chart.container, hasPointerEvent ? 'pointerdown' : 'MSPointerDown', this.onContainerPointerDown); + fn(this.chart.container, hasPointerEvent ? 'pointermove' : 'MSPointerMove', this.onContainerPointerMove); + fn(doc, hasPointerEvent ? 'pointerup' : 'MSPointerUp', this.onDocumentPointerUp); + } + }); + + // Disable default IE actions for pinch and such on chart element + wrap(Pointer.prototype, 'init', function (proceed, chart, options) { + proceed.call(this, chart, options); + if (this.hasZoom || this.followTouchMove) { + css(chart.container, { + '-ms-touch-action': NONE, + 'touch-action': NONE + }); + } + }); + + // Add IE specific touch events to chart + wrap(Pointer.prototype, 'setDOMEvents', function (proceed) { + proceed.apply(this); + if (this.hasZoom || this.followTouchMove) { + this.batchMSEvents(addEvent); + } + }); + // Destroy MS events also + wrap(Pointer.prototype, 'destroy', function (proceed) { + this.batchMSEvents(removeEvent); + proceed.call(this); + }); +} +/** + * The overview of the chart's series + */ +var Legend = Highcharts.Legend = function (chart, options) { + this.init(chart, options); +}; + +Legend.prototype = { + + /** + * Initialize the legend + */ + init: function (chart, options) { + + var legend = this, + itemStyle = options.itemStyle, + padding = pick(options.padding, 8), + itemMarginTop = options.itemMarginTop || 0; + + this.options = options; + + if (!options.enabled) { + return; + } + + legend.itemStyle = itemStyle; + legend.itemHiddenStyle = merge(itemStyle, options.itemHiddenStyle); + legend.itemMarginTop = itemMarginTop; + legend.padding = padding; + legend.initialItemX = padding; + legend.initialItemY = padding - 5; // 5 is the number of pixels above the text + legend.maxItemWidth = 0; + legend.chart = chart; + legend.itemHeight = 0; + legend.lastLineHeight = 0; + legend.symbolWidth = pick(options.symbolWidth, 16); + legend.pages = []; + + + // Render it + legend.render(); + + // move checkboxes + addEvent(legend.chart, 'endResize', function () { + legend.positionCheckboxes(); + }); + + }, + + /** + * Set the colors for the legend item + * @param {Object} item A Series or Point instance + * @param {Object} visible Dimmed or colored + */ + colorizeItem: function (item, visible) { + var legend = this, + options = legend.options, + legendItem = item.legendItem, + legendLine = item.legendLine, + legendSymbol = item.legendSymbol, + hiddenColor = legend.itemHiddenStyle.color, + textColor = visible ? options.itemStyle.color : hiddenColor, + symbolColor = visible ? (item.legendColor || item.color || '#CCC') : hiddenColor, + markerOptions = item.options && item.options.marker, + symbolAttr = { fill: symbolColor }, + key, + val; + + if (legendItem) { + legendItem.css({ fill: textColor, color: textColor }); // color for #1553, oldIE + } + if (legendLine) { + legendLine.attr({ stroke: symbolColor }); + } + + if (legendSymbol) { + + // Apply marker options + if (markerOptions && legendSymbol.isMarker) { // #585 + symbolAttr.stroke = symbolColor; + markerOptions = item.convertAttribs(markerOptions); + for (key in markerOptions) { + val = markerOptions[key]; + if (val !== UNDEFINED) { + symbolAttr[key] = val; + } + } + } + + legendSymbol.attr(symbolAttr); + } + }, + + /** + * Position the legend item + * @param {Object} item A Series or Point instance + */ + positionItem: function (item) { + var legend = this, + options = legend.options, + symbolPadding = options.symbolPadding, + ltr = !options.rtl, + legendItemPos = item._legendItemPos, + itemX = legendItemPos[0], + itemY = legendItemPos[1], + checkbox = item.checkbox; + + if (item.legendGroup) { + item.legendGroup.translate( + ltr ? itemX : legend.legendWidth - itemX - 2 * symbolPadding - 4, + itemY + ); + } + + if (checkbox) { + checkbox.x = itemX; + checkbox.y = itemY; + } + }, + + /** + * Destroy a single legend item + * @param {Object} item The series or point + */ + destroyItem: function (item) { + var checkbox = item.checkbox; + + // destroy SVG elements + each(['legendItem', 'legendLine', 'legendSymbol', 'legendGroup'], function (key) { + if (item[key]) { + item[key] = item[key].destroy(); + } + }); + + if (checkbox) { + discardElement(item.checkbox); + } + }, + + /** + * Destroys the legend. + */ + destroy: function () { + var legend = this, + legendGroup = legend.group, + box = legend.box; + + if (box) { + legend.box = box.destroy(); + } + + if (legendGroup) { + legend.group = legendGroup.destroy(); + } + }, + + /** + * Position the checkboxes after the width is determined + */ + positionCheckboxes: function (scrollOffset) { + var alignAttr = this.group.alignAttr, + translateY, + clipHeight = this.clipHeight || this.legendHeight; + + if (alignAttr) { + translateY = alignAttr.translateY; + each(this.allItems, function (item) { + var checkbox = item.checkbox, + top; + + if (checkbox) { + top = (translateY + checkbox.y + (scrollOffset || 0) + 3); + css(checkbox, { + left: (alignAttr.translateX + item.checkboxOffset + checkbox.x - 20) + PX, + top: top + PX, + display: top > translateY - 6 && top < translateY + clipHeight - 6 ? '' : NONE + }); + } + }); + } + }, + + /** + * Render the legend title on top of the legend + */ + renderTitle: function () { + var options = this.options, + padding = this.padding, + titleOptions = options.title, + titleHeight = 0, + bBox; + + if (titleOptions.text) { + if (!this.title) { + this.title = this.chart.renderer.label(titleOptions.text, padding - 3, padding - 4, null, null, null, null, null, 'legend-title') + .attr({ zIndex: 1 }) + .css(titleOptions.style) + .add(this.group); + } + bBox = this.title.getBBox(); + titleHeight = bBox.height; + this.offsetWidth = bBox.width; // #1717 + this.contentGroup.attr({ translateY: titleHeight }); + } + this.titleHeight = titleHeight; + }, + + /** + * Render a single specific legend item + * @param {Object} item A series or point + */ + renderItem: function (item) { + var legend = this, + chart = legend.chart, + renderer = chart.renderer, + options = legend.options, + horizontal = options.layout === 'horizontal', + symbolWidth = legend.symbolWidth, + symbolPadding = options.symbolPadding, + itemStyle = legend.itemStyle, + itemHiddenStyle = legend.itemHiddenStyle, + padding = legend.padding, + itemDistance = horizontal ? pick(options.itemDistance, 20) : 0, + ltr = !options.rtl, + itemHeight, + widthOption = options.width, + itemMarginBottom = options.itemMarginBottom || 0, + itemMarginTop = legend.itemMarginTop, + initialItemX = legend.initialItemX, + bBox, + itemWidth, + li = item.legendItem, + series = item.series && item.series.drawLegendSymbol ? item.series : item, + seriesOptions = series.options, + showCheckbox = legend.createCheckboxForItem && seriesOptions && seriesOptions.showCheckbox, + useHTML = options.useHTML; + + if (!li) { // generate it once, later move it + + // Generate the group box + // A group to hold the symbol and text. Text is to be appended in Legend class. + item.legendGroup = renderer.g('legend-item') + .attr({ zIndex: 1 }) + .add(legend.scrollGroup); + + // Generate the list item text and add it to the group + item.legendItem = li = renderer.text( + options.labelFormat ? format(options.labelFormat, item) : options.labelFormatter.call(item), + ltr ? symbolWidth + symbolPadding : -symbolPadding, + legend.baseline || 0, + useHTML + ) + .css(merge(item.visible ? itemStyle : itemHiddenStyle)) // merge to prevent modifying original (#1021) + .attr({ + align: ltr ? 'left' : 'right', + zIndex: 2 + }) + .add(item.legendGroup); + + // Get the baseline for the first item - the font size is equal for all + if (!legend.baseline) { + legend.baseline = renderer.fontMetrics(itemStyle.fontSize, li).f + 3 + itemMarginTop; + li.attr('y', legend.baseline); + } + + // Draw the legend symbol inside the group box + series.drawLegendSymbol(legend, item); + + if (legend.setItemEvents) { + legend.setItemEvents(item, li, useHTML, itemStyle, itemHiddenStyle); + } + + // Colorize the items + legend.colorizeItem(item, item.visible); + + // add the HTML checkbox on top + if (showCheckbox) { + legend.createCheckboxForItem(item); + } + } + + // calculate the positions for the next line + bBox = li.getBBox(); + + itemWidth = item.checkboxOffset = + options.itemWidth || + item.legendItemWidth || + symbolWidth + symbolPadding + bBox.width + itemDistance + (showCheckbox ? 20 : 0); + legend.itemHeight = itemHeight = mathRound(item.legendItemHeight || bBox.height); + + // if the item exceeds the width, start a new line + if (horizontal && legend.itemX - initialItemX + itemWidth > + (widthOption || (chart.chartWidth - 2 * padding - initialItemX - options.x))) { + legend.itemX = initialItemX; + legend.itemY += itemMarginTop + legend.lastLineHeight + itemMarginBottom; + legend.lastLineHeight = 0; // reset for next line + } + + // If the item exceeds the height, start a new column + /*if (!horizontal && legend.itemY + options.y + itemHeight > chart.chartHeight - spacingTop - spacingBottom) { + legend.itemY = legend.initialItemY; + legend.itemX += legend.maxItemWidth; + legend.maxItemWidth = 0; + }*/ + + // Set the edge positions + legend.maxItemWidth = mathMax(legend.maxItemWidth, itemWidth); + legend.lastItemY = itemMarginTop + legend.itemY + itemMarginBottom; + legend.lastLineHeight = mathMax(itemHeight, legend.lastLineHeight); // #915 + + // cache the position of the newly generated or reordered items + item._legendItemPos = [legend.itemX, legend.itemY]; + + // advance + if (horizontal) { + legend.itemX += itemWidth; + + } else { + legend.itemY += itemMarginTop + itemHeight + itemMarginBottom; + legend.lastLineHeight = itemHeight; + } + + // the width of the widest item + legend.offsetWidth = widthOption || mathMax( + (horizontal ? legend.itemX - initialItemX - itemDistance : itemWidth) + padding, + legend.offsetWidth + ); + }, + + /** + * Get all items, which is one item per series for normal series and one item per point + * for pie series. + */ + getAllItems: function () { + var allItems = []; + each(this.chart.series, function (series) { + var seriesOptions = series.options; + + // Handle showInLegend. If the series is linked to another series, defaults to false. + if (!pick(seriesOptions.showInLegend, !defined(seriesOptions.linkedTo) ? UNDEFINED : false, true)) { + return; + } + + // use points or series for the legend item depending on legendType + allItems = allItems.concat( + series.legendItems || + (seriesOptions.legendType === 'point' ? + series.data : + series) + ); + }); + return allItems; + }, + + /** + * Render the legend. This method can be called both before and after + * chart.render. If called after, it will only rearrange items instead + * of creating new ones. + */ + render: function () { + var legend = this, + chart = legend.chart, + renderer = chart.renderer, + legendGroup = legend.group, + allItems, + display, + legendWidth, + legendHeight, + box = legend.box, + options = legend.options, + padding = legend.padding, + legendBorderWidth = options.borderWidth, + legendBackgroundColor = options.backgroundColor; + + legend.itemX = legend.initialItemX; + legend.itemY = legend.initialItemY; + legend.offsetWidth = 0; + legend.lastItemY = 0; + + if (!legendGroup) { + legend.group = legendGroup = renderer.g('legend') + .attr({ zIndex: 7 }) + .add(); + legend.contentGroup = renderer.g() + .attr({ zIndex: 1 }) // above background + .add(legendGroup); + legend.scrollGroup = renderer.g() + .add(legend.contentGroup); + } + + legend.renderTitle(); + + // add each series or point + allItems = legend.getAllItems(); + + // sort by legendIndex + stableSort(allItems, function (a, b) { + return ((a.options && a.options.legendIndex) || 0) - ((b.options && b.options.legendIndex) || 0); + }); + + // reversed legend + if (options.reversed) { + allItems.reverse(); + } + + legend.allItems = allItems; + legend.display = display = !!allItems.length; + + // render the items + each(allItems, function (item) { + legend.renderItem(item); + }); + + // Draw the border + legendWidth = options.width || legend.offsetWidth; + legendHeight = legend.lastItemY + legend.lastLineHeight + legend.titleHeight; + + + legendHeight = legend.handleOverflow(legendHeight); + + if (legendBorderWidth || legendBackgroundColor) { + legendWidth += padding; + legendHeight += padding; + + if (!box) { + legend.box = box = renderer.rect( + 0, + 0, + legendWidth, + legendHeight, + options.borderRadius, + legendBorderWidth || 0 + ).attr({ + stroke: options.borderColor, + 'stroke-width': legendBorderWidth || 0, + fill: legendBackgroundColor || NONE + }) + .add(legendGroup) + .shadow(options.shadow); + box.isNew = true; + + } else if (legendWidth > 0 && legendHeight > 0) { + box[box.isNew ? 'attr' : 'animate']( + box.crisp({ width: legendWidth, height: legendHeight }) + ); + box.isNew = false; + } + + // hide the border if no items + box[display ? 'show' : 'hide'](); + } + + legend.legendWidth = legendWidth; + legend.legendHeight = legendHeight; + + // Now that the legend width and height are established, put the items in the + // final position + each(allItems, function (item) { + legend.positionItem(item); + }); + + // 1.x compatibility: positioning based on style + /*var props = ['left', 'right', 'top', 'bottom'], + prop, + i = 4; + while (i--) { + prop = props[i]; + if (options.style[prop] && options.style[prop] !== 'auto') { + options[i < 2 ? 'align' : 'verticalAlign'] = prop; + options[i < 2 ? 'x' : 'y'] = pInt(options.style[prop]) * (i % 2 ? -1 : 1); + } + }*/ + + if (display) { + legendGroup.align(extend({ + width: legendWidth, + height: legendHeight + }, options), true, 'spacingBox'); + } + + if (!chart.isResizing) { + this.positionCheckboxes(); + } + }, + + /** + * Set up the overflow handling by adding navigation with up and down arrows below the + * legend. + */ + handleOverflow: function (legendHeight) { + var legend = this, + chart = this.chart, + renderer = chart.renderer, + options = this.options, + optionsY = options.y, + alignTop = options.verticalAlign === 'top', + spaceHeight = chart.spacingBox.height + (alignTop ? -optionsY : optionsY) - this.padding, + maxHeight = options.maxHeight, + clipHeight, + clipRect = this.clipRect, + navOptions = options.navigation, + animation = pick(navOptions.animation, true), + arrowSize = navOptions.arrowSize || 12, + nav = this.nav, + pages = this.pages, + lastY, + allItems = this.allItems; + + // Adjust the height + if (options.layout === 'horizontal') { + spaceHeight /= 2; + } + if (maxHeight) { + spaceHeight = mathMin(spaceHeight, maxHeight); + } + + // Reset the legend height and adjust the clipping rectangle + pages.length = 0; + if (legendHeight > spaceHeight && !options.useHTML) { + + this.clipHeight = clipHeight = mathMax(spaceHeight - 20 - this.titleHeight - this.padding, 0); + this.currentPage = pick(this.currentPage, 1); + this.fullHeight = legendHeight; + + // Fill pages with Y positions so that the top of each a legend item defines + // the scroll top for each page (#2098) + each(allItems, function (item, i) { + var y = item._legendItemPos[1], + h = mathRound(item.legendItem.getBBox().height), + len = pages.length; + + if (!len || (y - pages[len - 1] > clipHeight && (lastY || y) !== pages[len - 1])) { + pages.push(lastY || y); + len++; + } + + if (i === allItems.length - 1 && y + h - pages[len - 1] > clipHeight) { + pages.push(y); + } + if (y !== lastY) { + lastY = y; + } + }); + + // Only apply clipping if needed. Clipping causes blurred legend in PDF export (#1787) + if (!clipRect) { + clipRect = legend.clipRect = renderer.clipRect(0, this.padding, 9999, 0); + legend.contentGroup.clip(clipRect); + } + clipRect.attr({ + height: clipHeight + }); + + // Add navigation elements + if (!nav) { + this.nav = nav = renderer.g().attr({ zIndex: 1 }).add(this.group); + this.up = renderer.symbol('triangle', 0, 0, arrowSize, arrowSize) + .on('click', function () { + legend.scroll(-1, animation); + }) + .add(nav); + this.pager = renderer.text('', 15, 10) + .css(navOptions.style) + .add(nav); + this.down = renderer.symbol('triangle-down', 0, 0, arrowSize, arrowSize) + .on('click', function () { + legend.scroll(1, animation); + }) + .add(nav); + } + + // Set initial position + legend.scroll(0); + + legendHeight = spaceHeight; + + } else if (nav) { + clipRect.attr({ + height: chart.chartHeight + }); + nav.hide(); + this.scrollGroup.attr({ + translateY: 1 + }); + this.clipHeight = 0; // #1379 + } + + return legendHeight; + }, + + /** + * Scroll the legend by a number of pages + * @param {Object} scrollBy + * @param {Object} animation + */ + scroll: function (scrollBy, animation) { + var pages = this.pages, + pageCount = pages.length, + currentPage = this.currentPage + scrollBy, + clipHeight = this.clipHeight, + navOptions = this.options.navigation, + activeColor = navOptions.activeColor, + inactiveColor = navOptions.inactiveColor, + pager = this.pager, + padding = this.padding, + scrollOffset; + + // When resizing while looking at the last page + if (currentPage > pageCount) { + currentPage = pageCount; + } + + if (currentPage > 0) { + + if (animation !== UNDEFINED) { + setAnimation(animation, this.chart); + } + + this.nav.attr({ + translateX: padding, + translateY: clipHeight + this.padding + 7 + this.titleHeight, + visibility: VISIBLE + }); + this.up.attr({ + fill: currentPage === 1 ? inactiveColor : activeColor + }) + .css({ + cursor: currentPage === 1 ? 'default' : 'pointer' + }); + pager.attr({ + text: currentPage + '/' + pageCount + }); + this.down.attr({ + x: 18 + this.pager.getBBox().width, // adjust to text width + fill: currentPage === pageCount ? inactiveColor : activeColor + }) + .css({ + cursor: currentPage === pageCount ? 'default' : 'pointer' + }); + + scrollOffset = -pages[currentPage - 1] + this.initialItemY; + + this.scrollGroup.animate({ + translateY: scrollOffset + }); + + this.currentPage = currentPage; + this.positionCheckboxes(scrollOffset); + } + + } + +}; + +/* + * LegendSymbolMixin + */ + +var LegendSymbolMixin = Highcharts.LegendSymbolMixin = { + + /** + * Get the series' symbol in the legend + * + * @param {Object} legend The legend object + * @param {Object} item The series (this) or point + */ + drawRectangle: function (legend, item) { + var symbolHeight = legend.options.symbolHeight || 12; + + item.legendSymbol = this.chart.renderer.rect( + 0, + legend.baseline - 5 - (symbolHeight / 2), + legend.symbolWidth, + symbolHeight, + legend.options.symbolRadius || 0 + ).attr({ + zIndex: 3 + }).add(item.legendGroup); + + }, + + /** + * Get the series' symbol in the legend. This method should be overridable to create custom + * symbols through Highcharts.seriesTypes[type].prototype.drawLegendSymbols. + * + * @param {Object} legend The legend object + */ + drawLineMarker: function (legend) { + + var options = this.options, + markerOptions = options.marker, + radius, + legendOptions = legend.options, + legendSymbol, + symbolWidth = legend.symbolWidth, + renderer = this.chart.renderer, + legendItemGroup = this.legendGroup, + verticalCenter = legend.baseline - mathRound(renderer.fontMetrics(legendOptions.itemStyle.fontSize, this.legendItem).b * 0.3), + attr; + + // Draw the line + if (options.lineWidth) { + attr = { + 'stroke-width': options.lineWidth + }; + if (options.dashStyle) { + attr.dashstyle = options.dashStyle; + } + this.legendLine = renderer.path([ + M, + 0, + verticalCenter, + L, + symbolWidth, + verticalCenter + ]) + .attr(attr) + .add(legendItemGroup); + } + + // Draw the marker + if (markerOptions && markerOptions.enabled !== false) { + radius = markerOptions.radius; + this.legendSymbol = legendSymbol = renderer.symbol( + this.symbol, + (symbolWidth / 2) - radius, + verticalCenter - radius, + 2 * radius, + 2 * radius + ) + .add(legendItemGroup); + legendSymbol.isMarker = true; + } + } +}; + +// Workaround for #2030, horizontal legend items not displaying in IE11 Preview, +// and for #2580, a similar drawing flaw in Firefox 26. +// TODO: Explore if there's a general cause for this. The problem may be related +// to nested group elements, as the legend item texts are within 4 group elements. +if (/Trident\/7\.0/.test(userAgent) || isFirefox) { + wrap(Legend.prototype, 'positionItem', function (proceed, item) { + var legend = this, + runPositionItem = function () { // If chart destroyed in sync, this is undefined (#2030) + if (item._legendItemPos) { + proceed.call(legend, item); + } + }; + + // Do it now, for export and to get checkbox placement + runPositionItem(); + + // Do it after to work around the core issue + setTimeout(runPositionItem); + }); +} +/** + * The chart class + * @param {Object} options + * @param {Function} callback Function to run when the chart has loaded + */ +function Chart() { + this.init.apply(this, arguments); +} + +Chart.prototype = { + + /** + * Initialize the chart + */ + init: function (userOptions, callback) { + + // Handle regular options + var options, + seriesOptions = userOptions.series; // skip merging data points to increase performance + + userOptions.series = null; + options = merge(defaultOptions, userOptions); // do the merge + options.series = userOptions.series = seriesOptions; // set back the series data + this.userOptions = userOptions; + + var optionsChart = options.chart; + + // Create margin & spacing array + this.margin = this.splashArray('margin', optionsChart); + this.spacing = this.splashArray('spacing', optionsChart); + + var chartEvents = optionsChart.events; + + //this.runChartClick = chartEvents && !!chartEvents.click; + this.bounds = { h: {}, v: {} }; // Pixel data bounds for touch zoom + + this.callback = callback; + this.isResizing = 0; + this.options = options; + //chartTitleOptions = UNDEFINED; + //chartSubtitleOptions = UNDEFINED; + + this.axes = []; + this.series = []; + this.hasCartesianSeries = optionsChart.showAxes; + //this.axisOffset = UNDEFINED; + //this.maxTicks = UNDEFINED; // handle the greatest amount of ticks on grouped axes + //this.inverted = UNDEFINED; + //this.loadingShown = UNDEFINED; + //this.container = UNDEFINED; + //this.chartWidth = UNDEFINED; + //this.chartHeight = UNDEFINED; + //this.marginRight = UNDEFINED; + //this.marginBottom = UNDEFINED; + //this.containerWidth = UNDEFINED; + //this.containerHeight = UNDEFINED; + //this.oldChartWidth = UNDEFINED; + //this.oldChartHeight = UNDEFINED; + + //this.renderTo = UNDEFINED; + //this.renderToClone = UNDEFINED; + + //this.spacingBox = UNDEFINED + + //this.legend = UNDEFINED; + + // Elements + //this.chartBackground = UNDEFINED; + //this.plotBackground = UNDEFINED; + //this.plotBGImage = UNDEFINED; + //this.plotBorder = UNDEFINED; + //this.loadingDiv = UNDEFINED; + //this.loadingSpan = UNDEFINED; + + var chart = this, + eventType; + + // Add the chart to the global lookup + chart.index = charts.length; + charts.push(chart); + chartCount++; + + // Set up auto resize + if (optionsChart.reflow !== false) { + addEvent(chart, 'load', function () { + chart.initReflow(); + }); + } + + // Chart event handlers + if (chartEvents) { + for (eventType in chartEvents) { + addEvent(chart, eventType, chartEvents[eventType]); + } + } + + chart.xAxis = []; + chart.yAxis = []; + + // Expose methods and variables + chart.animation = useCanVG ? false : pick(optionsChart.animation, true); + chart.pointCount = chart.colorCounter = chart.symbolCounter = 0; + + chart.firstRender(); + }, + + /** + * Initialize an individual series, called internally before render time + */ + initSeries: function (options) { + var chart = this, + optionsChart = chart.options.chart, + type = options.type || optionsChart.type || optionsChart.defaultSeriesType, + series, + constr = seriesTypes[type]; + + // No such series type + if (!constr) { + error(17, true); + } + + series = new constr(); + series.init(this, options); + return series; + }, + + /** + * Check whether a given point is within the plot area + * + * @param {Number} plotX Pixel x relative to the plot area + * @param {Number} plotY Pixel y relative to the plot area + * @param {Boolean} inverted Whether the chart is inverted + */ + isInsidePlot: function (plotX, plotY, inverted) { + var x = inverted ? plotY : plotX, + y = inverted ? plotX : plotY; + + return x >= 0 && + x <= this.plotWidth && + y >= 0 && + y <= this.plotHeight; + }, + + /** + * Adjust all axes tick amounts + */ + adjustTickAmounts: function () { + if (this.options.chart.alignTicks !== false) { + each(this.axes, function (axis) { + axis.adjustTickAmount(); + }); + } + this.maxTicks = null; + }, + + /** + * Redraw legend, axes or series based on updated data + * + * @param {Boolean|Object} animation Whether to apply animation, and optionally animation + * configuration + */ + redraw: function (animation) { + var chart = this, + axes = chart.axes, + series = chart.series, + pointer = chart.pointer, + legend = chart.legend, + redrawLegend = chart.isDirtyLegend, + hasStackedSeries, + hasDirtyStacks, + hasCartesianSeries = chart.hasCartesianSeries, + isDirtyBox = chart.isDirtyBox, // todo: check if it has actually changed? + seriesLength = series.length, + i = seriesLength, + serie, + renderer = chart.renderer, + isHiddenChart = renderer.isHidden(), + afterRedraw = []; + + setAnimation(animation, chart); + + if (isHiddenChart) { + chart.cloneRenderTo(); + } + + // Adjust title layout (reflow multiline text) + chart.layOutTitles(); + + // link stacked series + while (i--) { + serie = series[i]; + + if (serie.options.stacking) { + hasStackedSeries = true; + + if (serie.isDirty) { + hasDirtyStacks = true; + break; + } + } + } + if (hasDirtyStacks) { // mark others as dirty + i = seriesLength; + while (i--) { + serie = series[i]; + if (serie.options.stacking) { + serie.isDirty = true; + } + } + } + + // handle updated data in the series + each(series, function (serie) { + if (serie.isDirty) { // prepare the data so axis can read it + if (serie.options.legendType === 'point') { + redrawLegend = true; + } + } + }); + + // handle added or removed series + if (redrawLegend && legend.options.enabled) { // series or pie points are added or removed + // draw legend graphics + legend.render(); + + chart.isDirtyLegend = false; + } + + // reset stacks + if (hasStackedSeries) { + chart.getStacks(); + } + + + if (hasCartesianSeries) { + if (!chart.isResizing) { + + // reset maxTicks + chart.maxTicks = null; + + // set axes scales + each(axes, function (axis) { + axis.setScale(); + }); + } + + chart.adjustTickAmounts(); + } + + chart.getMargins(); // #3098 + + if (hasCartesianSeries) { + // If one axis is dirty, all axes must be redrawn (#792, #2169) + each(axes, function (axis) { + if (axis.isDirty) { + isDirtyBox = true; + } + }); + + // redraw axes + each(axes, function (axis) { + + // Fire 'afterSetExtremes' only if extremes are set + if (axis.isDirtyExtremes) { // #821 + axis.isDirtyExtremes = false; + afterRedraw.push(function () { // prevent a recursive call to chart.redraw() (#1119) + fireEvent(axis, 'afterSetExtremes', extend(axis.eventArgs, axis.getExtremes())); // #747, #751 + delete axis.eventArgs; + }); + } + + if (isDirtyBox || hasStackedSeries) { + axis.redraw(); + } + }); + } + + // the plot areas size has changed + if (isDirtyBox) { + chart.drawChartBox(); + } + + + // redraw affected series + each(series, function (serie) { + if (serie.isDirty && serie.visible && + (!serie.isCartesian || serie.xAxis)) { // issue #153 + serie.redraw(); + } + }); + + // move tooltip or reset + if (pointer) { + pointer.reset(true); + } + + // redraw if canvas + renderer.draw(); + + // fire the event + fireEvent(chart, 'redraw'); // jQuery breaks this when calling it from addEvent. Overwrites chart.redraw + + if (isHiddenChart) { + chart.cloneRenderTo(true); + } + + // Fire callbacks that are put on hold until after the redraw + each(afterRedraw, function (callback) { + callback.call(); + }); + }, + + /** + * Get an axis, series or point object by id. + * @param id {String} The id as given in the configuration options + */ + get: function (id) { + var chart = this, + axes = chart.axes, + series = chart.series; + + var i, + j, + points; + + // search axes + for (i = 0; i < axes.length; i++) { + if (axes[i].options.id === id) { + return axes[i]; + } + } + + // search series + for (i = 0; i < series.length; i++) { + if (series[i].options.id === id) { + return series[i]; + } + } + + // search points + for (i = 0; i < series.length; i++) { + points = series[i].points || []; + for (j = 0; j < points.length; j++) { + if (points[j].id === id) { + return points[j]; + } + } + } + return null; + }, + + /** + * Create the Axis instances based on the config options + */ + getAxes: function () { + var chart = this, + options = this.options, + xAxisOptions = options.xAxis = splat(options.xAxis || {}), + yAxisOptions = options.yAxis = splat(options.yAxis || {}), + optionsArray, + axis; + + // make sure the options are arrays and add some members + each(xAxisOptions, function (axis, i) { + axis.index = i; + axis.isX = true; + }); + + each(yAxisOptions, function (axis, i) { + axis.index = i; + }); + + // concatenate all axis options into one array + optionsArray = xAxisOptions.concat(yAxisOptions); + + each(optionsArray, function (axisOptions) { + axis = new Axis(chart, axisOptions); + }); + + chart.adjustTickAmounts(); + }, + + + /** + * Get the currently selected points from all series + */ + getSelectedPoints: function () { + var points = []; + each(this.series, function (serie) { + points = points.concat(grep(serie.points || [], function (point) { + return point.selected; + })); + }); + return points; + }, + + /** + * Get the currently selected series + */ + getSelectedSeries: function () { + return grep(this.series, function (serie) { + return serie.selected; + }); + }, + + /** + * Generate stacks for each series and calculate stacks total values + */ + getStacks: function () { + var chart = this; + + // reset stacks for each yAxis + each(chart.yAxis, function (axis) { + if (axis.stacks && axis.hasVisibleSeries) { + axis.oldStacks = axis.stacks; + } + }); + + each(chart.series, function (series) { + if (series.options.stacking && (series.visible === true || chart.options.chart.ignoreHiddenSeries === false)) { + series.stackKey = series.type + pick(series.options.stack, ''); + } + }); + }, + + /** + * Show the title and subtitle of the chart + * + * @param titleOptions {Object} New title options + * @param subtitleOptions {Object} New subtitle options + * + */ + setTitle: function (titleOptions, subtitleOptions, redraw) { + var chart = this, + options = chart.options, + chartTitleOptions, + chartSubtitleOptions; + + chartTitleOptions = options.title = merge(options.title, titleOptions); + chartSubtitleOptions = options.subtitle = merge(options.subtitle, subtitleOptions); + + // add title and subtitle + each([ + ['title', titleOptions, chartTitleOptions], + ['subtitle', subtitleOptions, chartSubtitleOptions] + ], function (arr) { + var name = arr[0], + title = chart[name], + titleOptions = arr[1], + chartTitleOptions = arr[2]; + + if (title && titleOptions) { + chart[name] = title = title.destroy(); // remove old + } + + if (chartTitleOptions && chartTitleOptions.text && !title) { + chart[name] = chart.renderer.text( + chartTitleOptions.text, + 0, + 0, + chartTitleOptions.useHTML + ) + .attr({ + align: chartTitleOptions.align, + 'class': PREFIX + name, + zIndex: chartTitleOptions.zIndex || 4 + }) + .css(chartTitleOptions.style) + .add(); + } + }); + chart.layOutTitles(redraw); + }, + + /** + * Lay out the chart titles and cache the full offset height for use in getMargins + */ + layOutTitles: function (redraw) { + var titleOffset = 0, + title = this.title, + subtitle = this.subtitle, + options = this.options, + titleOptions = options.title, + subtitleOptions = options.subtitle, + requiresDirtyBox, + renderer = this.renderer, + autoWidth = this.spacingBox.width - 44; // 44 makes room for default context button + + if (title) { + title + .css({ width: (titleOptions.width || autoWidth) + PX }) + .align(extend({ + y: renderer.fontMetrics(titleOptions.style.fontSize, title).b - 3 + }, titleOptions), false, 'spacingBox'); + + if (!titleOptions.floating && !titleOptions.verticalAlign) { + titleOffset = title.getBBox().height; + } + } + if (subtitle) { + subtitle + .css({ width: (subtitleOptions.width || autoWidth) + PX }) + .align(extend({ + y: titleOffset + (titleOptions.margin - 13) + renderer.fontMetrics(titleOptions.style.fontSize, subtitle).b + }, subtitleOptions), false, 'spacingBox'); + + if (!subtitleOptions.floating && !subtitleOptions.verticalAlign) { + titleOffset = mathCeil(titleOffset + subtitle.getBBox().height); + } + } + + requiresDirtyBox = this.titleOffset !== titleOffset; + this.titleOffset = titleOffset; // used in getMargins + + if (!this.isDirtyBox && requiresDirtyBox) { + this.isDirtyBox = requiresDirtyBox; + // Redraw if necessary (#2719, #2744) + if (this.hasRendered && pick(redraw, true) && this.isDirtyBox) { + this.redraw(); + } + } + }, + + /** + * Get chart width and height according to options and container size + */ + getChartSize: function () { + var chart = this, + optionsChart = chart.options.chart, + widthOption = optionsChart.width, + heightOption = optionsChart.height, + renderTo = chart.renderToClone || chart.renderTo; + + // get inner width and height from jQuery (#824) + if (!defined(widthOption)) { + chart.containerWidth = adapterRun(renderTo, 'width'); + } + if (!defined(heightOption)) { + chart.containerHeight = adapterRun(renderTo, 'height'); + } + + chart.chartWidth = mathMax(0, widthOption || chart.containerWidth || 600); // #1393, 1460 + chart.chartHeight = mathMax(0, pick(heightOption, + // the offsetHeight of an empty container is 0 in standard browsers, but 19 in IE7: + chart.containerHeight > 19 ? chart.containerHeight : 400)); + }, + + /** + * Create a clone of the chart's renderTo div and place it outside the viewport to allow + * size computation on chart.render and chart.redraw + */ + cloneRenderTo: function (revert) { + var clone = this.renderToClone, + container = this.container; + + // Destroy the clone and bring the container back to the real renderTo div + if (revert) { + if (clone) { + this.renderTo.appendChild(container); + discardElement(clone); + delete this.renderToClone; + } + + // Set up the clone + } else { + if (container && container.parentNode === this.renderTo) { + this.renderTo.removeChild(container); // do not clone this + } + this.renderToClone = clone = this.renderTo.cloneNode(0); + css(clone, { + position: ABSOLUTE, + top: '-9999px', + display: 'block' // #833 + }); + if (clone.style.setProperty) { // #2631 + clone.style.setProperty('display', 'block', 'important'); + } + doc.body.appendChild(clone); + if (container) { + clone.appendChild(container); + } + } + }, + + /** + * Get the containing element, determine the size and create the inner container + * div to hold the chart + */ + getContainer: function () { + var chart = this, + container, + optionsChart = chart.options.chart, + chartWidth, + chartHeight, + renderTo, + indexAttrName = 'data-highcharts-chart', + oldChartIndex, + containerId; + + chart.renderTo = renderTo = optionsChart.renderTo; + containerId = PREFIX + idCounter++; + + if (isString(renderTo)) { + chart.renderTo = renderTo = doc.getElementById(renderTo); + } + + // Display an error if the renderTo is wrong + if (!renderTo) { + error(13, true); + } + + // If the container already holds a chart, destroy it. The check for hasRendered is there + // because web pages that are saved to disk from the browser, will preserve the data-highcharts-chart + // attribute and the SVG contents, but not an interactive chart. So in this case, + // charts[oldChartIndex] will point to the wrong chart if any (#2609). + oldChartIndex = pInt(attr(renderTo, indexAttrName)); + if (!isNaN(oldChartIndex) && charts[oldChartIndex] && charts[oldChartIndex].hasRendered) { + charts[oldChartIndex].destroy(); + } + + // Make a reference to the chart from the div + attr(renderTo, indexAttrName, chart.index); + + // remove previous chart + renderTo.innerHTML = ''; + + // If the container doesn't have an offsetWidth, it has or is a child of a node + // that has display:none. We need to temporarily move it out to a visible + // state to determine the size, else the legend and tooltips won't render + // properly. The allowClone option is used in sparklines as a micro optimization, + // saving about 1-2 ms each chart. + if (!optionsChart.skipClone && !renderTo.offsetWidth) { + chart.cloneRenderTo(); + } + + // get the width and height + chart.getChartSize(); + chartWidth = chart.chartWidth; + chartHeight = chart.chartHeight; + + // create the inner container + chart.container = container = createElement(DIV, { + className: PREFIX + 'container' + + (optionsChart.className ? ' ' + optionsChart.className : ''), + id: containerId + }, extend({ + position: RELATIVE, + overflow: HIDDEN, // needed for context menu (avoid scrollbars) and + // content overflow in IE + width: chartWidth + PX, + height: chartHeight + PX, + textAlign: 'left', + lineHeight: 'normal', // #427 + zIndex: 0, // #1072 + '-webkit-tap-highlight-color': 'rgba(0,0,0,0)' + }, optionsChart.style), + chart.renderToClone || renderTo + ); + + // cache the cursor (#1650) + chart._cursor = container.style.cursor; + + // Initialize the renderer + chart.renderer = + optionsChart.forExport ? // force SVG, used for SVG export + new SVGRenderer(container, chartWidth, chartHeight, optionsChart.style, true) : + new Renderer(container, chartWidth, chartHeight, optionsChart.style); + + if (useCanVG) { + // If we need canvg library, extend and configure the renderer + // to get the tracker for translating mouse events + chart.renderer.create(chart, container, chartWidth, chartHeight); + } + }, + + /** + * Calculate margins by rendering axis labels in a preliminary position. Title, + * subtitle and legend have already been rendered at this stage, but will be + * moved into their final positions + */ + getMargins: function () { + var chart = this, + spacing = chart.spacing, + axisOffset, + legend = chart.legend, + margin = chart.margin, + legendOptions = chart.options.legend, + legendMargin = pick(legendOptions.margin, 20), + legendX = legendOptions.x, + legendY = legendOptions.y, + align = legendOptions.align, + verticalAlign = legendOptions.verticalAlign, + titleOffset = chart.titleOffset; + + chart.resetMargins(); + axisOffset = chart.axisOffset; + + // Adjust for title and subtitle + if (titleOffset && !defined(margin[0])) { + chart.plotTop = mathMax(chart.plotTop, titleOffset + chart.options.title.margin + spacing[0]); + } + + // Adjust for legend + if (legend.display && !legendOptions.floating) { + if (align === 'right') { // horizontal alignment handled first + if (!defined(margin[1])) { + chart.marginRight = mathMax( + chart.marginRight, + legend.legendWidth - legendX + legendMargin + spacing[1] + ); + } + } else if (align === 'left') { + if (!defined(margin[3])) { + chart.plotLeft = mathMax( + chart.plotLeft, + legend.legendWidth + legendX + legendMargin + spacing[3] + ); + } + + } else if (verticalAlign === 'top') { + if (!defined(margin[0])) { + chart.plotTop = mathMax( + chart.plotTop, + legend.legendHeight + legendY + legendMargin + spacing[0] + ); + } + + } else if (verticalAlign === 'bottom') { + if (!defined(margin[2])) { + chart.marginBottom = mathMax( + chart.marginBottom, + legend.legendHeight - legendY + legendMargin + spacing[2] + ); + } + } + } + + // adjust for scroller + if (chart.extraBottomMargin) { + chart.marginBottom += chart.extraBottomMargin; + } + if (chart.extraTopMargin) { + chart.plotTop += chart.extraTopMargin; + } + + // pre-render axes to get labels offset width + if (chart.hasCartesianSeries) { + each(chart.axes, function (axis) { + axis.getOffset(); + }); + } + + if (!defined(margin[3])) { + chart.plotLeft += axisOffset[3]; + } + if (!defined(margin[0])) { + chart.plotTop += axisOffset[0]; + } + if (!defined(margin[2])) { + chart.marginBottom += axisOffset[2]; + } + if (!defined(margin[1])) { + chart.marginRight += axisOffset[1]; + } + + chart.setChartSize(); + + }, + + /** + * Resize the chart to its container if size is not explicitly set + */ + reflow: function (e) { + var chart = this, + optionsChart = chart.options.chart, + renderTo = chart.renderTo, + width = optionsChart.width || adapterRun(renderTo, 'width'), + height = optionsChart.height || adapterRun(renderTo, 'height'), + target = e ? e.target : win, // #805 - MooTools doesn't supply e + doReflow = function () { + if (chart.container) { // It may have been destroyed in the meantime (#1257) + chart.setSize(width, height, false); + chart.hasUserSize = null; + } + }; + + // Width and height checks for display:none. Target is doc in IE8 and Opera, + // win in Firefox, Chrome and IE9. + if (!chart.hasUserSize && width && height && (target === win || target === doc)) { + if (width !== chart.containerWidth || height !== chart.containerHeight) { + clearTimeout(chart.reflowTimeout); + if (e) { // Called from window.resize + chart.reflowTimeout = setTimeout(doReflow, 100); + } else { // Called directly (#2224) + doReflow(); + } + } + chart.containerWidth = width; + chart.containerHeight = height; + } + }, + + /** + * Add the event handlers necessary for auto resizing + */ + initReflow: function () { + var chart = this, + reflow = function (e) { + chart.reflow(e); + }; + + + addEvent(win, 'resize', reflow); + addEvent(chart, 'destroy', function () { + removeEvent(win, 'resize', reflow); + }); + }, + + /** + * Resize the chart to a given width and height + * @param {Number} width + * @param {Number} height + * @param {Object|Boolean} animation + */ + setSize: function (width, height, animation) { + var chart = this, + chartWidth, + chartHeight, + fireEndResize; + + // Handle the isResizing counter + chart.isResizing += 1; + fireEndResize = function () { + if (chart) { + fireEvent(chart, 'endResize', null, function () { + chart.isResizing -= 1; + }); + } + }; + + // set the animation for the current process + setAnimation(animation, chart); + + chart.oldChartHeight = chart.chartHeight; + chart.oldChartWidth = chart.chartWidth; + if (defined(width)) { + chart.chartWidth = chartWidth = mathMax(0, mathRound(width)); + chart.hasUserSize = !!chartWidth; + } + if (defined(height)) { + chart.chartHeight = chartHeight = mathMax(0, mathRound(height)); + } + + // Resize the container with the global animation applied if enabled (#2503) + (globalAnimation ? animate : css)(chart.container, { + width: chartWidth + PX, + height: chartHeight + PX + }, globalAnimation); + + chart.setChartSize(true); + chart.renderer.setSize(chartWidth, chartHeight, animation); + + // handle axes + chart.maxTicks = null; + each(chart.axes, function (axis) { + axis.isDirty = true; + axis.setScale(); + }); + + // make sure non-cartesian series are also handled + each(chart.series, function (serie) { + serie.isDirty = true; + }); + + chart.isDirtyLegend = true; // force legend redraw + chart.isDirtyBox = true; // force redraw of plot and chart border + + chart.layOutTitles(); // #2857 + chart.getMargins(); + + chart.redraw(animation); + + + chart.oldChartHeight = null; + fireEvent(chart, 'resize'); + + // fire endResize and set isResizing back + // If animation is disabled, fire without delay + if (globalAnimation === false) { + fireEndResize(); + } else { // else set a timeout with the animation duration + setTimeout(fireEndResize, (globalAnimation && globalAnimation.duration) || 500); + } + }, + + /** + * Set the public chart properties. This is done before and after the pre-render + * to determine margin sizes + */ + setChartSize: function (skipAxes) { + var chart = this, + inverted = chart.inverted, + renderer = chart.renderer, + chartWidth = chart.chartWidth, + chartHeight = chart.chartHeight, + optionsChart = chart.options.chart, + spacing = chart.spacing, + clipOffset = chart.clipOffset, + clipX, + clipY, + plotLeft, + plotTop, + plotWidth, + plotHeight, + plotBorderWidth; + + chart.plotLeft = plotLeft = mathRound(chart.plotLeft); + chart.plotTop = plotTop = mathRound(chart.plotTop); + chart.plotWidth = plotWidth = mathMax(0, mathRound(chartWidth - plotLeft - chart.marginRight)); + chart.plotHeight = plotHeight = mathMax(0, mathRound(chartHeight - plotTop - chart.marginBottom)); + + chart.plotSizeX = inverted ? plotHeight : plotWidth; + chart.plotSizeY = inverted ? plotWidth : plotHeight; + + chart.plotBorderWidth = optionsChart.plotBorderWidth || 0; + + // Set boxes used for alignment + chart.spacingBox = renderer.spacingBox = { + x: spacing[3], + y: spacing[0], + width: chartWidth - spacing[3] - spacing[1], + height: chartHeight - spacing[0] - spacing[2] + }; + chart.plotBox = renderer.plotBox = { + x: plotLeft, + y: plotTop, + width: plotWidth, + height: plotHeight + }; + + plotBorderWidth = 2 * mathFloor(chart.plotBorderWidth / 2); + clipX = mathCeil(mathMax(plotBorderWidth, clipOffset[3]) / 2); + clipY = mathCeil(mathMax(plotBorderWidth, clipOffset[0]) / 2); + chart.clipBox = { + x: clipX, + y: clipY, + width: mathFloor(chart.plotSizeX - mathMax(plotBorderWidth, clipOffset[1]) / 2 - clipX), + height: mathMax(0, mathFloor(chart.plotSizeY - mathMax(plotBorderWidth, clipOffset[2]) / 2 - clipY)) + }; + + if (!skipAxes) { + each(chart.axes, function (axis) { + axis.setAxisSize(); + axis.setAxisTranslation(); + }); + } + }, + + /** + * Initial margins before auto size margins are applied + */ + resetMargins: function () { + var chart = this, + spacing = chart.spacing, + margin = chart.margin; + + chart.plotTop = pick(margin[0], spacing[0]); + chart.marginRight = pick(margin[1], spacing[1]); + chart.marginBottom = pick(margin[2], spacing[2]); + chart.plotLeft = pick(margin[3], spacing[3]); + chart.axisOffset = [0, 0, 0, 0]; // top, right, bottom, left + chart.clipOffset = [0, 0, 0, 0]; + }, + + /** + * Draw the borders and backgrounds for chart and plot area + */ + drawChartBox: function () { + var chart = this, + optionsChart = chart.options.chart, + renderer = chart.renderer, + chartWidth = chart.chartWidth, + chartHeight = chart.chartHeight, + chartBackground = chart.chartBackground, + plotBackground = chart.plotBackground, + plotBorder = chart.plotBorder, + plotBGImage = chart.plotBGImage, + chartBorderWidth = optionsChart.borderWidth || 0, + chartBackgroundColor = optionsChart.backgroundColor, + plotBackgroundColor = optionsChart.plotBackgroundColor, + plotBackgroundImage = optionsChart.plotBackgroundImage, + plotBorderWidth = optionsChart.plotBorderWidth || 0, + mgn, + bgAttr, + plotLeft = chart.plotLeft, + plotTop = chart.plotTop, + plotWidth = chart.plotWidth, + plotHeight = chart.plotHeight, + plotBox = chart.plotBox, + clipRect = chart.clipRect, + clipBox = chart.clipBox; + + // Chart area + mgn = chartBorderWidth + (optionsChart.shadow ? 8 : 0); + + if (chartBorderWidth || chartBackgroundColor) { + if (!chartBackground) { + + bgAttr = { + fill: chartBackgroundColor || NONE + }; + if (chartBorderWidth) { // #980 + bgAttr.stroke = optionsChart.borderColor; + bgAttr['stroke-width'] = chartBorderWidth; + } + chart.chartBackground = renderer.rect(mgn / 2, mgn / 2, chartWidth - mgn, chartHeight - mgn, + optionsChart.borderRadius, chartBorderWidth) + .attr(bgAttr) + .addClass(PREFIX + 'background') + .add() + .shadow(optionsChart.shadow); + + } else { // resize + chartBackground.animate( + chartBackground.crisp({ width: chartWidth - mgn, height: chartHeight - mgn }) + ); + } + } + + + // Plot background + if (plotBackgroundColor) { + if (!plotBackground) { + chart.plotBackground = renderer.rect(plotLeft, plotTop, plotWidth, plotHeight, 0) + .attr({ + fill: plotBackgroundColor + }) + .add() + .shadow(optionsChart.plotShadow); + } else { + plotBackground.animate(plotBox); + } + } + if (plotBackgroundImage) { + if (!plotBGImage) { + chart.plotBGImage = renderer.image(plotBackgroundImage, plotLeft, plotTop, plotWidth, plotHeight) + .add(); + } else { + plotBGImage.animate(plotBox); + } + } + + // Plot clip + if (!clipRect) { + chart.clipRect = renderer.clipRect(clipBox); + } else { + clipRect.animate({ + width: clipBox.width, + height: clipBox.height + }); + } + + // Plot area border + if (plotBorderWidth) { + if (!plotBorder) { + chart.plotBorder = renderer.rect(plotLeft, plotTop, plotWidth, plotHeight, 0, -plotBorderWidth) + .attr({ + stroke: optionsChart.plotBorderColor, + 'stroke-width': plotBorderWidth, + fill: NONE, + zIndex: 1 + }) + .add(); + } else { + plotBorder.animate( + plotBorder.crisp({ x: plotLeft, y: plotTop, width: plotWidth, height: plotHeight }) + ); + } + } + + // reset + chart.isDirtyBox = false; + }, + + /** + * Detect whether a certain chart property is needed based on inspecting its options + * and series. This mainly applies to the chart.invert property, and in extensions to + * the chart.angular and chart.polar properties. + */ + propFromSeries: function () { + var chart = this, + optionsChart = chart.options.chart, + klass, + seriesOptions = chart.options.series, + i, + value; + + + each(['inverted', 'angular', 'polar'], function (key) { + + // The default series type's class + klass = seriesTypes[optionsChart.type || optionsChart.defaultSeriesType]; + + // Get the value from available chart-wide properties + value = ( + chart[key] || // 1. it is set before + optionsChart[key] || // 2. it is set in the options + (klass && klass.prototype[key]) // 3. it's default series class requires it + ); + + // 4. Check if any the chart's series require it + i = seriesOptions && seriesOptions.length; + while (!value && i--) { + klass = seriesTypes[seriesOptions[i].type]; + if (klass && klass.prototype[key]) { + value = true; + } + } + + // Set the chart property + chart[key] = value; + }); + + }, + + /** + * Link two or more series together. This is done initially from Chart.render, + * and after Chart.addSeries and Series.remove. + */ + linkSeries: function () { + var chart = this, + chartSeries = chart.series; + + // Reset links + each(chartSeries, function (series) { + series.linkedSeries.length = 0; + }); + + // Apply new links + each(chartSeries, function (series) { + var linkedTo = series.options.linkedTo; + if (isString(linkedTo)) { + if (linkedTo === ':previous') { + linkedTo = chart.series[series.index - 1]; + } else { + linkedTo = chart.get(linkedTo); + } + if (linkedTo) { + linkedTo.linkedSeries.push(series); + series.linkedParent = linkedTo; + } + } + }); + }, + + /** + * Render series for the chart + */ + renderSeries: function () { + each(this.series, function (serie) { + serie.translate(); + if (serie.setTooltipPoints) { + serie.setTooltipPoints(); + } + serie.render(); + }); + }, + + /** + * Render labels for the chart + */ + renderLabels: function () { + var chart = this, + labels = chart.options.labels; + if (labels.items) { + each(labels.items, function (label) { + var style = extend(labels.style, label.style), + x = pInt(style.left) + chart.plotLeft, + y = pInt(style.top) + chart.plotTop + 12; + + // delete to prevent rewriting in IE + delete style.left; + delete style.top; + + chart.renderer.text( + label.html, + x, + y + ) + .attr({ zIndex: 2 }) + .css(style) + .add(); + + }); + } + }, + + /** + * Render all graphics for the chart + */ + render: function () { + var chart = this, + axes = chart.axes, + renderer = chart.renderer, + options = chart.options; + + // Title + chart.setTitle(); + + + // Legend + chart.legend = new Legend(chart, options.legend); + + chart.getStacks(); // render stacks + + // Get margins by pre-rendering axes + // set axes scales + each(axes, function (axis) { + axis.setScale(); + }); + + chart.getMargins(); + + chart.maxTicks = null; // reset for second pass + each(axes, function (axis) { + axis.setTickPositions(true); // update to reflect the new margins + axis.setMaxTicks(); + }); + chart.adjustTickAmounts(); + chart.getMargins(); // second pass to check for new labels + + + // Draw the borders and backgrounds + chart.drawChartBox(); + + + // Axes + if (chart.hasCartesianSeries) { + each(axes, function (axis) { + axis.render(); + }); + } + + // The series + if (!chart.seriesGroup) { + chart.seriesGroup = renderer.g('series-group') + .attr({ zIndex: 3 }) + .add(); + } + chart.renderSeries(); + + // Labels + chart.renderLabels(); + + // Credits + chart.showCredits(options.credits); + + // Set flag + chart.hasRendered = true; + + }, + + /** + * Show chart credits based on config options + */ + showCredits: function (credits) { + if (credits.enabled && !this.credits) { + this.credits = this.renderer.text( + credits.text, + 0, + 0 + ) + .on('click', function () { + if (credits.href) { + location.href = credits.href; + } + }) + .attr({ + align: credits.position.align, + zIndex: 8 + }) + .css(credits.style) + .add() + .align(credits.position); + } + }, + + /** + * Clean up memory usage + */ + destroy: function () { + var chart = this, + axes = chart.axes, + series = chart.series, + container = chart.container, + i, + parentNode = container && container.parentNode; + + // fire the chart.destoy event + fireEvent(chart, 'destroy'); + + // Delete the chart from charts lookup array + charts[chart.index] = UNDEFINED; + chartCount--; + chart.renderTo.removeAttribute('data-highcharts-chart'); + + // remove events + removeEvent(chart); + + // ==== Destroy collections: + // Destroy axes + i = axes.length; + while (i--) { + axes[i] = axes[i].destroy(); + } + + // Destroy each series + i = series.length; + while (i--) { + series[i] = series[i].destroy(); + } + + // ==== Destroy chart properties: + each(['title', 'subtitle', 'chartBackground', 'plotBackground', 'plotBGImage', + 'plotBorder', 'seriesGroup', 'clipRect', 'credits', 'pointer', 'scroller', + 'rangeSelector', 'legend', 'resetZoomButton', 'tooltip', 'renderer'], function (name) { + var prop = chart[name]; + + if (prop && prop.destroy) { + chart[name] = prop.destroy(); + } + }); + + // remove container and all SVG + if (container) { // can break in IE when destroyed before finished loading + container.innerHTML = ''; + removeEvent(container); + if (parentNode) { + discardElement(container); + } + + } + + // clean it all up + for (i in chart) { + delete chart[i]; + } + + }, + + + /** + * VML namespaces can't be added until after complete. Listening + * for Perini's doScroll hack is not enough. + */ + isReadyToRender: function () { + var chart = this; + + // Note: in spite of JSLint's complaints, win == win.top is required + /*jslint eqeq: true*/ + if ((!hasSVG && (win == win.top && doc.readyState !== 'complete')) || (useCanVG && !win.canvg)) { + /*jslint eqeq: false*/ + if (useCanVG) { + // Delay rendering until canvg library is downloaded and ready + CanVGController.push(function () { chart.firstRender(); }, chart.options.global.canvasToolsURL); + } else { + doc.attachEvent('onreadystatechange', function () { + doc.detachEvent('onreadystatechange', chart.firstRender); + if (doc.readyState === 'complete') { + chart.firstRender(); + } + }); + } + return false; + } + return true; + }, + + /** + * Prepare for first rendering after all data are loaded + */ + firstRender: function () { + var chart = this, + options = chart.options, + callback = chart.callback; + + // Check whether the chart is ready to render + if (!chart.isReadyToRender()) { + return; + } + + // Create the container + chart.getContainer(); + + // Run an early event after the container and renderer are established + fireEvent(chart, 'init'); + + + chart.resetMargins(); + chart.setChartSize(); + + // Set the common chart properties (mainly invert) from the given series + chart.propFromSeries(); + + // get axes + chart.getAxes(); + + // Initialize the series + each(options.series || [], function (serieOptions) { + chart.initSeries(serieOptions); + }); + + chart.linkSeries(); + + // Run an event after axes and series are initialized, but before render. At this stage, + // the series data is indexed and cached in the xData and yData arrays, so we can access + // those before rendering. Used in Highstock. + fireEvent(chart, 'beforeRender'); + + // depends on inverted and on margins being set + if (Highcharts.Pointer) { + chart.pointer = new Pointer(chart, options); + } + + chart.render(); + + // add canvas + chart.renderer.draw(); + // run callbacks + if (callback) { + callback.apply(chart, [chart]); + } + each(chart.callbacks, function (fn) { + fn.apply(chart, [chart]); + }); + + + // If the chart was rendered outside the top container, put it back in + chart.cloneRenderTo(true); + + fireEvent(chart, 'load'); + + }, + + /** + * Creates arrays for spacing and margin from given options. + */ + splashArray: function (target, options) { + var oVar = options[target], + tArray = isObject(oVar) ? oVar : [oVar, oVar, oVar, oVar]; + + return [pick(options[target + 'Top'], tArray[0]), + pick(options[target + 'Right'], tArray[1]), + pick(options[target + 'Bottom'], tArray[2]), + pick(options[target + 'Left'], tArray[3])]; + } +}; // end Chart + +// Hook for exporting module +Chart.prototype.callbacks = []; + +var CenteredSeriesMixin = Highcharts.CenteredSeriesMixin = { + /** + * Get the center of the pie based on the size and center options relative to the + * plot area. Borrowed by the polar and gauge series types. + */ + getCenter: function () { + + var options = this.options, + chart = this.chart, + slicingRoom = 2 * (options.slicedOffset || 0), + handleSlicingRoom, + plotWidth = chart.plotWidth - 2 * slicingRoom, + plotHeight = chart.plotHeight - 2 * slicingRoom, + centerOption = options.center, + positions = [pick(centerOption[0], '50%'), pick(centerOption[1], '50%'), options.size || '100%', options.innerSize || 0], + smallestSize = mathMin(plotWidth, plotHeight), + isPercent; + + return map(positions, function (length, i) { + isPercent = /%$/.test(length); + handleSlicingRoom = i < 2 || (i === 2 && isPercent); + return (isPercent ? + // i == 0: centerX, relative to width + // i == 1: centerY, relative to height + // i == 2: size, relative to smallestSize + // i == 4: innerSize, relative to smallestSize + [plotWidth, plotHeight, smallestSize, smallestSize][i] * + pInt(length) / 100 : + length) + (handleSlicingRoom ? slicingRoom : 0); + }); + } +}; + +/** + * The Point object and prototype. Inheritable and used as base for PiePoint + */ +var Point = function () {}; +Point.prototype = { + + /** + * Initialize the point + * @param {Object} series The series object containing this point + * @param {Object} options The data in either number, array or object format + */ + init: function (series, options, x) { + + var point = this, + colors; + point.series = series; + point.applyOptions(options, x); + point.pointAttr = {}; + + if (series.options.colorByPoint) { + colors = series.options.colors || series.chart.options.colors; + point.color = point.color || colors[series.colorCounter++]; + // loop back to zero + if (series.colorCounter === colors.length) { + series.colorCounter = 0; + } + } + + series.chart.pointCount++; + return point; + }, + /** + * Apply the options containing the x and y data and possible some extra properties. + * This is called on point init or from point.update. + * + * @param {Object} options + */ + applyOptions: function (options, x) { + var point = this, + series = point.series, + pointValKey = series.options.pointValKey || series.pointValKey; + + options = Point.prototype.optionsToObject.call(this, options); + + // copy options directly to point + extend(point, options); + point.options = point.options ? extend(point.options, options) : options; + + // For higher dimension series types. For instance, for ranges, point.y is mapped to point.low. + if (pointValKey) { + point.y = point[pointValKey]; + } + + // If no x is set by now, get auto incremented value. All points must have an + // x value, however the y value can be null to create a gap in the series + if (point.x === UNDEFINED && series) { + point.x = x === UNDEFINED ? series.autoIncrement() : x; + } + + return point; + }, + + /** + * Transform number or array configs into objects + */ + optionsToObject: function (options) { + var ret = {}, + series = this.series, + pointArrayMap = series.pointArrayMap || ['y'], + valueCount = pointArrayMap.length, + firstItemType, + i = 0, + j = 0; + + if (typeof options === 'number' || options === null) { + ret[pointArrayMap[0]] = options; + + } else if (isArray(options)) { + // with leading x value + if (options.length > valueCount) { + firstItemType = typeof options[0]; + if (firstItemType === 'string') { + ret.name = options[0]; + } else if (firstItemType === 'number') { + ret.x = options[0]; + } + i++; + } + while (j < valueCount) { + ret[pointArrayMap[j++]] = options[i++]; + } + } else if (typeof options === 'object') { + ret = options; + + // This is the fastest way to detect if there are individual point dataLabels that need + // to be considered in drawDataLabels. These can only occur in object configs. + if (options.dataLabels) { + series._hasPointLabels = true; + } + + // Same approach as above for markers + if (options.marker) { + series._hasPointMarkers = true; + } + } + return ret; + }, + + /** + * Destroy a point to clear memory. Its reference still stays in series.data. + */ + destroy: function () { + var point = this, + series = point.series, + chart = series.chart, + hoverPoints = chart.hoverPoints, + prop; + + chart.pointCount--; + + if (hoverPoints) { + point.setState(); + erase(hoverPoints, point); + if (!hoverPoints.length) { + chart.hoverPoints = null; + } + + } + if (point === chart.hoverPoint) { + point.onMouseOut(); + } + + // remove all events + if (point.graphic || point.dataLabel) { // removeEvent and destroyElements are performance expensive + removeEvent(point); + point.destroyElements(); + } + + if (point.legendItem) { // pies have legend items + chart.legend.destroyItem(point); + } + + for (prop in point) { + point[prop] = null; + } + + + }, + + /** + * Destroy SVG elements associated with the point + */ + destroyElements: function () { + var point = this, + props = ['graphic', 'dataLabel', 'dataLabelUpper', 'group', 'connector', 'shadowGroup'], + prop, + i = 6; + while (i--) { + prop = props[i]; + if (point[prop]) { + point[prop] = point[prop].destroy(); + } + } + }, + + /** + * Return the configuration hash needed for the data label and tooltip formatters + */ + getLabelConfig: function () { + var point = this; + return { + x: point.category, + y: point.y, + key: point.name || point.category, + series: point.series, + point: point, + percentage: point.percentage, + total: point.total || point.stackTotal + }; + }, + + /** + * Extendable method for formatting each point's tooltip line + * + * @return {String} A string to be concatenated in to the common tooltip text + */ + tooltipFormatter: function (pointFormat) { + + // Insert options for valueDecimals, valuePrefix, and valueSuffix + var series = this.series, + seriesTooltipOptions = series.tooltipOptions, + valueDecimals = pick(seriesTooltipOptions.valueDecimals, ''), + valuePrefix = seriesTooltipOptions.valuePrefix || '', + valueSuffix = seriesTooltipOptions.valueSuffix || ''; + + // Loop over the point array map and replace unformatted values with sprintf formatting markup + each(series.pointArrayMap || ['y'], function (key) { + key = '{point.' + key; // without the closing bracket + if (valuePrefix || valueSuffix) { + pointFormat = pointFormat.replace(key + '}', valuePrefix + key + '}' + valueSuffix); + } + pointFormat = pointFormat.replace(key + '}', key + ':,.' + valueDecimals + 'f}'); + }); + + return format(pointFormat, { + point: this, + series: this.series + }); + }, + + /** + * Fire an event on the Point object. Must not be renamed to fireEvent, as this + * causes a name clash in MooTools + * @param {String} eventType + * @param {Object} eventArgs Additional event arguments + * @param {Function} defaultFunction Default event handler + */ + firePointEvent: function (eventType, eventArgs, defaultFunction) { + var point = this, + series = this.series, + seriesOptions = series.options; + + // load event handlers on demand to save time on mouseover/out + if (seriesOptions.point.events[eventType] || (point.options && point.options.events && point.options.events[eventType])) { + this.importEvents(); + } + + // add default handler if in selection mode + if (eventType === 'click' && seriesOptions.allowPointSelect) { + defaultFunction = function (event) { + // Control key is for Windows, meta (= Cmd key) for Mac, Shift for Opera + point.select(null, event.ctrlKey || event.metaKey || event.shiftKey); + }; + } + + fireEvent(this, eventType, eventArgs, defaultFunction); + } +};/** + * @classDescription The base function which all other series types inherit from. The data in the series is stored + * in various arrays. + * + * - First, series.options.data contains all the original config options for + * each point whether added by options or methods like series.addPoint. + * - Next, series.data contains those values converted to points, but in case the series data length + * exceeds the cropThreshold, or if the data is grouped, series.data doesn't contain all the points. It + * only contains the points that have been created on demand. + * - Then there's series.points that contains all currently visible point objects. In case of cropping, + * the cropped-away points are not part of this array. The series.points array starts at series.cropStart + * compared to series.data and series.options.data. If however the series data is grouped, these can't + * be correlated one to one. + * - series.xData and series.processedXData contain clean x values, equivalent to series.data and series.points. + * - series.yData and series.processedYData contain clean x values, equivalent to series.data and series.points. + * + * @param {Object} chart + * @param {Object} options + */ +var Series = function () {}; + +Series.prototype = { + + isCartesian: true, + type: 'line', + pointClass: Point, + sorted: true, // requires the data to be sorted + requireSorting: true, + pointAttrToOptions: { // mapping between SVG attributes and the corresponding options + stroke: 'lineColor', + 'stroke-width': 'lineWidth', + fill: 'fillColor', + r: 'radius' + }, + axisTypes: ['xAxis', 'yAxis'], + colorCounter: 0, + parallelArrays: ['x', 'y'], // each point's x and y values are stored in this.xData and this.yData + init: function (chart, options) { + var series = this, + eventType, + events, + chartSeries = chart.series, + sortByIndex = function (a, b) { + return pick(a.options.index, a._i) - pick(b.options.index, b._i); + }; + + series.chart = chart; + series.options = options = series.setOptions(options); // merge with plotOptions + series.linkedSeries = []; + + // bind the axes + series.bindAxes(); + + // set some variables + extend(series, { + name: options.name, + state: NORMAL_STATE, + pointAttr: {}, + visible: options.visible !== false, // true by default + selected: options.selected === true // false by default + }); + + // special + if (useCanVG) { + options.animation = false; + } + + // register event listeners + events = options.events; + for (eventType in events) { + addEvent(series, eventType, events[eventType]); + } + if ( + (events && events.click) || + (options.point && options.point.events && options.point.events.click) || + options.allowPointSelect + ) { + chart.runTrackerClick = true; + } + + series.getColor(); + series.getSymbol(); + + // Set the data + each(series.parallelArrays, function (key) { + series[key + 'Data'] = []; + }); + series.setData(options.data, false); + + // Mark cartesian + if (series.isCartesian) { + chart.hasCartesianSeries = true; + } + + // Register it in the chart + chartSeries.push(series); + series._i = chartSeries.length - 1; + + // Sort series according to index option (#248, #1123, #2456) + stableSort(chartSeries, sortByIndex); + if (this.yAxis) { + stableSort(this.yAxis.series, sortByIndex); + } + + each(chartSeries, function (series, i) { + series.index = i; + series.name = series.name || 'Series ' + (i + 1); + }); + + }, + + /** + * Set the xAxis and yAxis properties of cartesian series, and register the series + * in the axis.series array + */ + bindAxes: function () { + var series = this, + seriesOptions = series.options, + chart = series.chart, + axisOptions; + + each(series.axisTypes || [], function (AXIS) { // repeat for xAxis and yAxis + + each(chart[AXIS], function (axis) { // loop through the chart's axis objects + axisOptions = axis.options; + + // apply if the series xAxis or yAxis option mathches the number of the + // axis, or if undefined, use the first axis + if ((seriesOptions[AXIS] === axisOptions.index) || + (seriesOptions[AXIS] !== UNDEFINED && seriesOptions[AXIS] === axisOptions.id) || + (seriesOptions[AXIS] === UNDEFINED && axisOptions.index === 0)) { + + // register this series in the axis.series lookup + axis.series.push(series); + + // set this series.xAxis or series.yAxis reference + series[AXIS] = axis; + + // mark dirty for redraw + axis.isDirty = true; + } + }); + + // The series needs an X and an Y axis + if (!series[AXIS] && series.optionalAxis !== AXIS) { + error(18, true); + } + + }); + }, + + /** + * For simple series types like line and column, the data values are held in arrays like + * xData and yData for quick lookup to find extremes and more. For multidimensional series + * like bubble and map, this can be extended with arrays like zData and valueData by + * adding to the series.parallelArrays array. + */ + updateParallelArrays: function (point, i) { + var series = point.series, + args = arguments, + fn = typeof i === 'number' ? + // Insert the value in the given position + function (key) { + var val = key === 'y' && series.toYData ? series.toYData(point) : point[key]; + series[key + 'Data'][i] = val; + } : + // Apply the method specified in i with the following arguments as arguments + function (key) { + Array.prototype[i].apply(series[key + 'Data'], Array.prototype.slice.call(args, 2)); + }; + + each(series.parallelArrays, fn); + }, + + /** + * Return an auto incremented x value based on the pointStart and pointInterval options. + * This is only used if an x value is not given for the point that calls autoIncrement. + */ + autoIncrement: function () { + var series = this, + options = series.options, + xIncrement = series.xIncrement; + + xIncrement = pick(xIncrement, options.pointStart, 0); + + series.pointInterval = pick(series.pointInterval, options.pointInterval, 1); + + series.xIncrement = xIncrement + series.pointInterval; + return xIncrement; + }, + + /** + * Divide the series data into segments divided by null values. + */ + getSegments: function () { + var series = this, + lastNull = -1, + segments = [], + i, + points = series.points, + pointsLength = points.length; + + if (pointsLength) { // no action required for [] + + // if connect nulls, just remove null points + if (series.options.connectNulls) { + i = pointsLength; + while (i--) { + if (points[i].y === null) { + points.splice(i, 1); + } + } + if (points.length) { + segments = [points]; + } + + // else, split on null points + } else { + each(points, function (point, i) { + if (point.y === null) { + if (i > lastNull + 1) { + segments.push(points.slice(lastNull + 1, i)); + } + lastNull = i; + } else if (i === pointsLength - 1) { // last value + segments.push(points.slice(lastNull + 1, i + 1)); + } + }); + } + } + + // register it + series.segments = segments; + }, + + /** + * Set the series options by merging from the options tree + * @param {Object} itemOptions + */ + setOptions: function (itemOptions) { + var chart = this.chart, + chartOptions = chart.options, + plotOptions = chartOptions.plotOptions, + userOptions = chart.userOptions || {}, + userPlotOptions = userOptions.plotOptions || {}, + typeOptions = plotOptions[this.type], + options; + + this.userOptions = itemOptions; + + options = merge( + typeOptions, + plotOptions.series, + itemOptions + ); + + // The tooltip options are merged between global and series specific options + this.tooltipOptions = merge( + defaultOptions.tooltip, + defaultOptions.plotOptions[this.type].tooltip, + userOptions.tooltip, + userPlotOptions.series && userPlotOptions.series.tooltip, + userPlotOptions[this.type] && userPlotOptions[this.type].tooltip, + itemOptions.tooltip + ); + + // Delete marker object if not allowed (#1125) + if (typeOptions.marker === null) { + delete options.marker; + } + + return options; + + }, + + getCyclic: function (prop, value, defaults) { + var i, + userOptions = this.userOptions, + indexName = '_' + prop + 'Index', + counterName = prop + 'Counter'; + + if (!value) { + if (defined(userOptions[indexName])) { // after Series.update() + i = userOptions[indexName]; + } else { + userOptions[indexName] = i = this.chart[counterName] % defaults.length; + this.chart[counterName] += 1; + } + value = defaults[i]; + } + this[prop] = value; + }, + + /** + * Get the series' color + */ + getColor: function () { + if (!this.options.colorByPoint) { + this.getCyclic('color', this.options.color || defaultPlotOptions[this.type].color, this.chart.options.colors); + } + }, + /** + * Get the series' symbol + */ + getSymbol: function () { + var seriesMarkerOption = this.options.marker; + + this.getCyclic('symbol', seriesMarkerOption.symbol, this.chart.options.symbols); + + // don't substract radius in image symbols (#604) + if (/^url/.test(this.symbol)) { + seriesMarkerOption.radius = 0; + } + }, + + drawLegendSymbol: LegendSymbolMixin.drawLineMarker, + + /** + * Replace the series data with a new set of data + * @param {Object} data + * @param {Object} redraw + */ + setData: function (data, redraw, animation, updatePoints) { + var series = this, + oldData = series.points, + oldDataLength = (oldData && oldData.length) || 0, + dataLength, + options = series.options, + chart = series.chart, + firstPoint = null, + xAxis = series.xAxis, + hasCategories = xAxis && !!xAxis.categories, + tooltipPoints = series.tooltipPoints, + i, + turboThreshold = options.turboThreshold, + pt, + xData = this.xData, + yData = this.yData, + pointArrayMap = series.pointArrayMap, + valueCount = pointArrayMap && pointArrayMap.length; + + data = data || []; + dataLength = data.length; + redraw = pick(redraw, true); + + // If the point count is the same as is was, just run Point.update which is + // cheaper, allows animation, and keeps references to points. + if (updatePoints !== false && dataLength && oldDataLength === dataLength && !series.cropped && !series.hasGroupedData) { + each(data, function (point, i) { + oldData[i].update(point, false); + }); + + } else { + + // Reset properties + series.xIncrement = null; + series.pointRange = hasCategories ? 1 : options.pointRange; + + series.colorCounter = 0; // for series with colorByPoint (#1547) + + // Update parallel arrays + each(this.parallelArrays, function (key) { + series[key + 'Data'].length = 0; + }); + + // In turbo mode, only one- or twodimensional arrays of numbers are allowed. The + // first value is tested, and we assume that all the rest are defined the same + // way. Although the 'for' loops are similar, they are repeated inside each + // if-else conditional for max performance. + if (turboThreshold && dataLength > turboThreshold) { + + // find the first non-null point + i = 0; + while (firstPoint === null && i < dataLength) { + firstPoint = data[i]; + i++; + } + + + if (isNumber(firstPoint)) { // assume all points are numbers + var x = pick(options.pointStart, 0), + pointInterval = pick(options.pointInterval, 1); + + for (i = 0; i < dataLength; i++) { + xData[i] = x; + yData[i] = data[i]; + x += pointInterval; + } + series.xIncrement = x; + } else if (isArray(firstPoint)) { // assume all points are arrays + if (valueCount) { // [x, low, high] or [x, o, h, l, c] + for (i = 0; i < dataLength; i++) { + pt = data[i]; + xData[i] = pt[0]; + yData[i] = pt.slice(1, valueCount + 1); + } + } else { // [x, y] + for (i = 0; i < dataLength; i++) { + pt = data[i]; + xData[i] = pt[0]; + yData[i] = pt[1]; + } + } + } else { + error(12); // Highcharts expects configs to be numbers or arrays in turbo mode + } + } else { + for (i = 0; i < dataLength; i++) { + if (data[i] !== UNDEFINED) { // stray commas in oldIE + pt = { series: series }; + series.pointClass.prototype.applyOptions.apply(pt, [data[i]]); + series.updateParallelArrays(pt, i); + if (hasCategories && pt.name) { + xAxis.names[pt.x] = pt.name; // #2046 + } + } + } + } + + // Forgetting to cast strings to numbers is a common caveat when handling CSV or JSON + if (isString(yData[0])) { + error(14, true); + } + + series.data = []; + series.options.data = data; + //series.zData = zData; + + // destroy old points + i = oldDataLength; + while (i--) { + if (oldData[i] && oldData[i].destroy) { + oldData[i].destroy(); + } + } + if (tooltipPoints) { // #2594 + tooltipPoints.length = 0; + } + + // reset minRange (#878) + if (xAxis) { + xAxis.minRange = xAxis.userMinRange; + } + + // redraw + series.isDirty = series.isDirtyData = chart.isDirtyBox = true; + animation = false; + } + + if (redraw) { + chart.redraw(animation); + } + }, + + /** + * Process the data by cropping away unused data points if the series is longer + * than the crop threshold. This saves computing time for lage series. + */ + processData: function (force) { + var series = this, + processedXData = series.xData, // copied during slice operation below + processedYData = series.yData, + dataLength = processedXData.length, + croppedData, + cropStart = 0, + cropped, + distance, + closestPointRange, + xAxis = series.xAxis, + i, // loop variable + options = series.options, + cropThreshold = options.cropThreshold, + activePointCount = 0, + isCartesian = series.isCartesian, + xExtremes, + min, + max; + + // If the series data or axes haven't changed, don't go through this. Return false to pass + // the message on to override methods like in data grouping. + if (isCartesian && !series.isDirty && !xAxis.isDirty && !series.yAxis.isDirty && !force) { + return false; + } + + + // optionally filter out points outside the plot area + if (isCartesian && series.sorted && (!cropThreshold || dataLength > cropThreshold || series.forceCrop)) { + + xExtremes = xAxis.getExtremes(); // corrected for log axis (#3053) + min = xExtremes.min; + max = xExtremes.max; + + // it's outside current extremes + if (processedXData[dataLength - 1] < min || processedXData[0] > max) { + processedXData = []; + processedYData = []; + + // only crop if it's actually spilling out + } else if (processedXData[0] < min || processedXData[dataLength - 1] > max) { + croppedData = this.cropData(series.xData, series.yData, min, max); + processedXData = croppedData.xData; + processedYData = croppedData.yData; + cropStart = croppedData.start; + cropped = true; + activePointCount = processedXData.length; + } + } + + + // Find the closest distance between processed points + for (i = processedXData.length - 1; i >= 0; i--) { + distance = processedXData[i] - processedXData[i - 1]; + + if (!cropped && processedXData[i] > min && processedXData[i] < max) { + activePointCount++; + } + if (distance > 0 && (closestPointRange === UNDEFINED || distance < closestPointRange)) { + closestPointRange = distance; + + // Unsorted data is not supported by the line tooltip, as well as data grouping and + // navigation in Stock charts (#725) and width calculation of columns (#1900) + } else if (distance < 0 && series.requireSorting) { + error(15); + } + } + + // Record the properties + series.cropped = cropped; // undefined or true + series.cropStart = cropStart; + series.processedXData = processedXData; + series.processedYData = processedYData; + series.activePointCount = activePointCount; + + if (options.pointRange === null) { // null means auto, as for columns, candlesticks and OHLC + series.pointRange = closestPointRange || 1; + } + series.closestPointRange = closestPointRange; + + }, + + /** + * Iterate over xData and crop values between min and max. Returns object containing crop start/end + * cropped xData with corresponding part of yData, dataMin and dataMax within the cropped range + */ + cropData: function (xData, yData, min, max) { + var dataLength = xData.length, + cropStart = 0, + cropEnd = dataLength, + cropShoulder = pick(this.cropShoulder, 1), // line-type series need one point outside + i; + + // iterate up to find slice start + for (i = 0; i < dataLength; i++) { + if (xData[i] >= min) { + cropStart = mathMax(0, i - cropShoulder); + break; + } + } + + // proceed to find slice end + for (; i < dataLength; i++) { + if (xData[i] > max) { + cropEnd = i + cropShoulder; + break; + } + } + + return { + xData: xData.slice(cropStart, cropEnd), + yData: yData.slice(cropStart, cropEnd), + start: cropStart, + end: cropEnd + }; + }, + + + /** + * Generate the data point after the data has been processed by cropping away + * unused points and optionally grouped in Highcharts Stock. + */ + generatePoints: function () { + var series = this, + options = series.options, + dataOptions = options.data, + data = series.data, + dataLength, + processedXData = series.processedXData, + processedYData = series.processedYData, + pointClass = series.pointClass, + processedDataLength = processedXData.length, + cropStart = series.cropStart || 0, + cursor, + hasGroupedData = series.hasGroupedData, + point, + points = [], + i; + + if (!data && !hasGroupedData) { + var arr = []; + arr.length = dataOptions.length; + data = series.data = arr; + } + + for (i = 0; i < processedDataLength; i++) { + cursor = cropStart + i; + if (!hasGroupedData) { + if (data[cursor]) { + point = data[cursor]; + } else if (dataOptions[cursor] !== UNDEFINED) { // #970 + data[cursor] = point = (new pointClass()).init(series, dataOptions[cursor], processedXData[i]); + } + points[i] = point; + } else { + // splat the y data in case of ohlc data array + points[i] = (new pointClass()).init(series, [processedXData[i]].concat(splat(processedYData[i]))); + } + } + + // Hide cropped-away points - this only runs when the number of points is above cropThreshold, or when + // swithching view from non-grouped data to grouped data (#637) + if (data && (processedDataLength !== (dataLength = data.length) || hasGroupedData)) { + for (i = 0; i < dataLength; i++) { + if (i === cropStart && !hasGroupedData) { // when has grouped data, clear all points + i += processedDataLength; + } + if (data[i]) { + data[i].destroyElements(); + data[i].plotX = UNDEFINED; // #1003 + } + } + } + + series.data = data; + series.points = points; + }, + + /** + * Calculate Y extremes for visible data + */ + getExtremes: function (yData) { + var xAxis = this.xAxis, + yAxis = this.yAxis, + xData = this.processedXData, + yDataLength, + activeYData = [], + activeCounter = 0, + xExtremes = xAxis.getExtremes(), // #2117, need to compensate for log X axis + xMin = xExtremes.min, + xMax = xExtremes.max, + validValue, + withinRange, + dataMin, + dataMax, + x, + y, + i, + j; + + yData = yData || this.stackedYData || this.processedYData; + yDataLength = yData.length; + + for (i = 0; i < yDataLength; i++) { + + x = xData[i]; + y = yData[i]; + + // For points within the visible range, including the first point outside the + // visible range, consider y extremes + validValue = y !== null && y !== UNDEFINED && (!yAxis.isLog || (y.length || y > 0)); + withinRange = this.getExtremesFromAll || this.cropped || ((xData[i + 1] || x) >= xMin && + (xData[i - 1] || x) <= xMax); + + if (validValue && withinRange) { + + j = y.length; + if (j) { // array, like ohlc or range data + while (j--) { + if (y[j] !== null) { + activeYData[activeCounter++] = y[j]; + } + } + } else { + activeYData[activeCounter++] = y; + } + } + } + this.dataMin = pick(dataMin, arrayMin(activeYData)); + this.dataMax = pick(dataMax, arrayMax(activeYData)); + }, + + /** + * Translate data points from raw data values to chart specific positioning data + * needed later in drawPoints, drawGraph and drawTracker. + */ + translate: function () { + if (!this.processedXData) { // hidden series + this.processData(); + } + this.generatePoints(); + var series = this, + options = series.options, + stacking = options.stacking, + xAxis = series.xAxis, + categories = xAxis.categories, + yAxis = series.yAxis, + points = series.points, + dataLength = points.length, + hasModifyValue = !!series.modifyValue, + i, + pointPlacement = options.pointPlacement, + dynamicallyPlaced = pointPlacement === 'between' || isNumber(pointPlacement), + threshold = options.threshold; + + // Translate each point + for (i = 0; i < dataLength; i++) { + var point = points[i], + xValue = point.x, + yValue = point.y, + yBottom = point.low, + stack = stacking && yAxis.stacks[(series.negStacks && yValue < threshold ? '-' : '') + series.stackKey], + pointStack, + stackValues; + + // Discard disallowed y values for log axes + if (yAxis.isLog && yValue <= 0) { + point.y = yValue = null; + } + + // Get the plotX translation + point.plotX = xAxis.translate(xValue, 0, 0, 0, 1, pointPlacement, this.type === 'flags'); // Math.round fixes #591 + + + // Calculate the bottom y value for stacked series + if (stacking && series.visible && stack && stack[xValue]) { + + pointStack = stack[xValue]; + stackValues = pointStack.points[series.index + ',' + i]; + yBottom = stackValues[0]; + yValue = stackValues[1]; + + if (yBottom === 0) { + yBottom = pick(threshold, yAxis.min); + } + if (yAxis.isLog && yBottom <= 0) { // #1200, #1232 + yBottom = null; + } + + point.total = point.stackTotal = pointStack.total; + point.percentage = pointStack.total && (point.y / pointStack.total * 100); + point.stackY = yValue; + + // Place the stack label + pointStack.setOffset(series.pointXOffset || 0, series.barW || 0); + + } + + // Set translated yBottom or remove it + point.yBottom = defined(yBottom) ? + yAxis.translate(yBottom, 0, 1, 0, 1) : + null; + + // general hook, used for Highstock compare mode + if (hasModifyValue) { + yValue = series.modifyValue(yValue, point); + } + + // Set the the plotY value, reset it for redraws + point.plotY = (typeof yValue === 'number' && yValue !== Infinity) ? + //mathRound(yAxis.translate(yValue, 0, 1, 0, 1) * 10) / 10 : // Math.round fixes #591 + yAxis.translate(yValue, 0, 1, 0, 1) : + UNDEFINED; + + // Set client related positions for mouse tracking + point.clientX = dynamicallyPlaced ? xAxis.translate(xValue, 0, 0, 0, 1) : point.plotX; // #1514 + + point.negative = point.y < (threshold || 0); + + // some API data + point.category = categories && categories[point.x] !== UNDEFINED ? + categories[point.x] : point.x; + + } + + // now that we have the cropped data, build the segments + series.getSegments(); + }, + + /** + * Animate in the series + */ + animate: function (init) { + var series = this, + chart = series.chart, + renderer = chart.renderer, + clipRect, + markerClipRect, + animation = series.options.animation, + clipBox = series.clipBox || chart.clipBox, + inverted = chart.inverted, + sharedClipKey; + + // Animation option is set to true + if (animation && !isObject(animation)) { + animation = defaultPlotOptions[series.type].animation; + } + sharedClipKey = ['_sharedClip', animation.duration, animation.easing, clipBox.height].join(','); + + // Initialize the animation. Set up the clipping rectangle. + if (init) { + + // If a clipping rectangle with the same properties is currently present in the chart, use that. + clipRect = chart[sharedClipKey]; + markerClipRect = chart[sharedClipKey + 'm']; + if (!clipRect) { + chart[sharedClipKey] = clipRect = renderer.clipRect( + extend(clipBox, { width: 0 }) + ); + + chart[sharedClipKey + 'm'] = markerClipRect = renderer.clipRect( + -99, // include the width of the first marker + inverted ? -chart.plotLeft : -chart.plotTop, + 99, + inverted ? chart.chartWidth : chart.chartHeight + ); + } + series.group.clip(clipRect); + series.markerGroup.clip(markerClipRect); + series.sharedClipKey = sharedClipKey; + + // Run the animation + } else { + clipRect = chart[sharedClipKey]; + if (clipRect) { + clipRect.animate({ + width: chart.plotSizeX + }, animation); + } + if (chart[sharedClipKey + 'm']) { + chart[sharedClipKey + 'm'].animate({ + width: chart.plotSizeX + 99 + }, animation); + } + + // Delete this function to allow it only once + series.animate = null; + + } + }, + + /** + * This runs after animation to land on the final plot clipping + */ + afterAnimate: function () { + var chart = this.chart, + sharedClipKey = this.sharedClipKey, + group = this.group, + clipBox = this.clipBox; + + if (group && this.options.clip !== false) { + if (!sharedClipKey || !clipBox) { + group.clip(clipBox ? chart.renderer.clipRect(clipBox) : chart.clipRect); + } + this.markerGroup.clip(); // no clip + } + + fireEvent(this, 'afterAnimate'); + + // Remove the shared clipping rectancgle when all series are shown + setTimeout(function () { + if (sharedClipKey && chart[sharedClipKey]) { + if (!clipBox) { + chart[sharedClipKey] = chart[sharedClipKey].destroy(); + } + if (chart[sharedClipKey + 'm']) { + chart[sharedClipKey + 'm'] = chart[sharedClipKey + 'm'].destroy(); + } + } + }, 100); + }, + + /** + * Draw the markers + */ + drawPoints: function () { + var series = this, + pointAttr, + points = series.points, + chart = series.chart, + plotX, + plotY, + i, + point, + radius, + symbol, + isImage, + graphic, + options = series.options, + seriesMarkerOptions = options.marker, + seriesPointAttr = series.pointAttr[''], + pointMarkerOptions, + enabled, + isInside, + markerGroup = series.markerGroup, + globallyEnabled = pick( + seriesMarkerOptions.enabled, + series.activePointCount < (0.5 * series.xAxis.len / seriesMarkerOptions.radius) + ); + + if (seriesMarkerOptions.enabled !== false || series._hasPointMarkers) { + + i = points.length; + while (i--) { + point = points[i]; + plotX = mathFloor(point.plotX); // #1843 + plotY = point.plotY; + graphic = point.graphic; + pointMarkerOptions = point.marker || {}; + enabled = (globallyEnabled && pointMarkerOptions.enabled === UNDEFINED) || pointMarkerOptions.enabled; + isInside = chart.isInsidePlot(mathRound(plotX), plotY, chart.inverted); // #1858 + + // only draw the point if y is defined + if (enabled && plotY !== UNDEFINED && !isNaN(plotY) && point.y !== null) { + + // shortcuts + pointAttr = point.pointAttr[point.selected ? SELECT_STATE : NORMAL_STATE] || seriesPointAttr; + radius = pointAttr.r; + symbol = pick(pointMarkerOptions.symbol, series.symbol); + isImage = symbol.indexOf('url') === 0; + + if (graphic) { // update + graphic[isInside ? 'show' : 'hide'](true) // Since the marker group isn't clipped, each individual marker must be toggled + .animate(extend({ + x: plotX - radius, + y: plotY - radius + }, graphic.symbolName ? { // don't apply to image symbols #507 + width: 2 * radius, + height: 2 * radius + } : {})); + } else if (isInside && (radius > 0 || isImage)) { + point.graphic = graphic = chart.renderer.symbol( + symbol, + plotX - radius, + plotY - radius, + 2 * radius, + 2 * radius + ) + .attr(pointAttr) + .add(markerGroup); + } + + } else if (graphic) { + point.graphic = graphic.destroy(); // #1269 + } + } + } + + }, + + /** + * Convert state properties from API naming conventions to SVG attributes + * + * @param {Object} options API options object + * @param {Object} base1 SVG attribute object to inherit from + * @param {Object} base2 Second level SVG attribute object to inherit from + */ + convertAttribs: function (options, base1, base2, base3) { + var conversion = this.pointAttrToOptions, + attr, + option, + obj = {}; + + options = options || {}; + base1 = base1 || {}; + base2 = base2 || {}; + base3 = base3 || {}; + + for (attr in conversion) { + option = conversion[attr]; + obj[attr] = pick(options[option], base1[attr], base2[attr], base3[attr]); + } + return obj; + }, + + /** + * Get the state attributes. Each series type has its own set of attributes + * that are allowed to change on a point's state change. Series wide attributes are stored for + * all series, and additionally point specific attributes are stored for all + * points with individual marker options. If such options are not defined for the point, + * a reference to the series wide attributes is stored in point.pointAttr. + */ + getAttribs: function () { + var series = this, + seriesOptions = series.options, + normalOptions = defaultPlotOptions[series.type].marker ? seriesOptions.marker : seriesOptions, + stateOptions = normalOptions.states, + stateOptionsHover = stateOptions[HOVER_STATE], + pointStateOptionsHover, + seriesColor = series.color, + normalDefaults = { + stroke: seriesColor, + fill: seriesColor + }, + points = series.points || [], // #927 + i, + point, + seriesPointAttr = [], + pointAttr, + pointAttrToOptions = series.pointAttrToOptions, + hasPointSpecificOptions = series.hasPointSpecificOptions, + negativeColor = seriesOptions.negativeColor, + defaultLineColor = normalOptions.lineColor, + defaultFillColor = normalOptions.fillColor, + turboThreshold = seriesOptions.turboThreshold, + attr, + key; + + // series type specific modifications + if (seriesOptions.marker) { // line, spline, area, areaspline, scatter + + // if no hover radius is given, default to normal radius + 2 + stateOptionsHover.radius = stateOptionsHover.radius || normalOptions.radius + stateOptionsHover.radiusPlus; + stateOptionsHover.lineWidth = stateOptionsHover.lineWidth || normalOptions.lineWidth + stateOptionsHover.lineWidthPlus; + + } else { // column, bar, pie + + // if no hover color is given, brighten the normal color + stateOptionsHover.color = stateOptionsHover.color || + Color(stateOptionsHover.color || seriesColor) + .brighten(stateOptionsHover.brightness).get(); + } + + // general point attributes for the series normal state + seriesPointAttr[NORMAL_STATE] = series.convertAttribs(normalOptions, normalDefaults); + + // HOVER_STATE and SELECT_STATE states inherit from normal state except the default radius + each([HOVER_STATE, SELECT_STATE], function (state) { + seriesPointAttr[state] = + series.convertAttribs(stateOptions[state], seriesPointAttr[NORMAL_STATE]); + }); + + // set it + series.pointAttr = seriesPointAttr; + + + // Generate the point-specific attribute collections if specific point + // options are given. If not, create a referance to the series wide point + // attributes + i = points.length; + if (!turboThreshold || i < turboThreshold || hasPointSpecificOptions) { + while (i--) { + point = points[i]; + normalOptions = (point.options && point.options.marker) || point.options; + if (normalOptions && normalOptions.enabled === false) { + normalOptions.radius = 0; + } + + if (point.negative && negativeColor) { + point.color = point.fillColor = negativeColor; + } + + hasPointSpecificOptions = seriesOptions.colorByPoint || point.color; // #868 + + // check if the point has specific visual options + if (point.options) { + for (key in pointAttrToOptions) { + if (defined(normalOptions[pointAttrToOptions[key]])) { + hasPointSpecificOptions = true; + } + } + } + + // a specific marker config object is defined for the individual point: + // create it's own attribute collection + if (hasPointSpecificOptions) { + normalOptions = normalOptions || {}; + pointAttr = []; + stateOptions = normalOptions.states || {}; // reassign for individual point + pointStateOptionsHover = stateOptions[HOVER_STATE] = stateOptions[HOVER_STATE] || {}; + + // Handle colors for column and pies + if (!seriesOptions.marker) { // column, bar, point + // If no hover color is given, brighten the normal color. #1619, #2579 + pointStateOptionsHover.color = pointStateOptionsHover.color || (!point.options.color && stateOptionsHover.color) || + Color(point.color) + .brighten(pointStateOptionsHover.brightness || stateOptionsHover.brightness) + .get(); + } + + // normal point state inherits series wide normal state + attr = { color: point.color }; // #868 + if (!defaultFillColor) { // Individual point color or negative color markers (#2219) + attr.fillColor = point.color; + } + if (!defaultLineColor) { + attr.lineColor = point.color; // Bubbles take point color, line markers use white + } + pointAttr[NORMAL_STATE] = series.convertAttribs(extend(attr, normalOptions), seriesPointAttr[NORMAL_STATE]); + + // inherit from point normal and series hover + pointAttr[HOVER_STATE] = series.convertAttribs( + stateOptions[HOVER_STATE], + seriesPointAttr[HOVER_STATE], + pointAttr[NORMAL_STATE] + ); + + // inherit from point normal and series hover + pointAttr[SELECT_STATE] = series.convertAttribs( + stateOptions[SELECT_STATE], + seriesPointAttr[SELECT_STATE], + pointAttr[NORMAL_STATE] + ); + + + // no marker config object is created: copy a reference to the series-wide + // attribute collection + } else { + pointAttr = seriesPointAttr; + } + + point.pointAttr = pointAttr; + } + } + }, + + /** + * Clear DOM objects and free up memory + */ + destroy: function () { + var series = this, + chart = series.chart, + issue134 = /AppleWebKit\/533/.test(userAgent), + destroy, + i, + data = series.data || [], + point, + prop, + axis; + + // add event hook + fireEvent(series, 'destroy'); + + // remove all events + removeEvent(series); + + // erase from axes + each(series.axisTypes || [], function (AXIS) { + axis = series[AXIS]; + if (axis) { + erase(axis.series, series); + axis.isDirty = axis.forceRedraw = true; + } + }); + + // remove legend items + if (series.legendItem) { + series.chart.legend.destroyItem(series); + } + + // destroy all points with their elements + i = data.length; + while (i--) { + point = data[i]; + if (point && point.destroy) { + point.destroy(); + } + } + series.points = null; + + // Clear the animation timeout if we are destroying the series during initial animation + clearTimeout(series.animationTimeout); + + // destroy all SVGElements associated to the series + each(['area', 'graph', 'dataLabelsGroup', 'group', 'markerGroup', 'tracker', + 'graphNeg', 'areaNeg', 'posClip', 'negClip'], function (prop) { + if (series[prop]) { + + // issue 134 workaround + destroy = issue134 && prop === 'group' ? + 'hide' : + 'destroy'; + + series[prop][destroy](); + } + }); + + // remove from hoverSeries + if (chart.hoverSeries === series) { + chart.hoverSeries = null; + } + erase(chart.series, series); + + // clear all members + for (prop in series) { + delete series[prop]; + } + }, + + /** + * Return the graph path of a segment + */ + getSegmentPath: function (segment) { + var series = this, + segmentPath = [], + step = series.options.step; + + // build the segment line + each(segment, function (point, i) { + + var plotX = point.plotX, + plotY = point.plotY, + lastPoint; + + if (series.getPointSpline) { // generate the spline as defined in the SplineSeries object + segmentPath.push.apply(segmentPath, series.getPointSpline(segment, point, i)); + + } else { + + // moveTo or lineTo + segmentPath.push(i ? L : M); + + // step line? + if (step && i) { + lastPoint = segment[i - 1]; + if (step === 'right') { + segmentPath.push( + lastPoint.plotX, + plotY + ); + + } else if (step === 'center') { + segmentPath.push( + (lastPoint.plotX + plotX) / 2, + lastPoint.plotY, + (lastPoint.plotX + plotX) / 2, + plotY + ); + + } else { + segmentPath.push( + plotX, + lastPoint.plotY + ); + } + } + + // normal line to next point + segmentPath.push( + point.plotX, + point.plotY + ); + } + }); + + return segmentPath; + }, + + /** + * Get the graph path + */ + getGraphPath: function () { + var series = this, + graphPath = [], + segmentPath, + singlePoints = []; // used in drawTracker + + // Divide into segments and build graph and area paths + each(series.segments, function (segment) { + + segmentPath = series.getSegmentPath(segment); + + // add the segment to the graph, or a single point for tracking + if (segment.length > 1) { + graphPath = graphPath.concat(segmentPath); + } else { + singlePoints.push(segment[0]); + } + }); + + // Record it for use in drawGraph and drawTracker, and return graphPath + series.singlePoints = singlePoints; + series.graphPath = graphPath; + + return graphPath; + + }, + + /** + * Draw the actual graph + */ + drawGraph: function () { + var series = this, + options = this.options, + props = [['graph', options.lineColor || this.color]], + lineWidth = options.lineWidth, + dashStyle = options.dashStyle, + roundCap = options.linecap !== 'square', + graphPath = this.getGraphPath(), + negativeColor = options.negativeColor; + + if (negativeColor) { + props.push(['graphNeg', negativeColor]); + } + + // draw the graph + each(props, function (prop, i) { + var graphKey = prop[0], + graph = series[graphKey], + attribs; + + if (graph) { + stop(graph); // cancel running animations, #459 + graph.animate({ d: graphPath }); + + } else if (lineWidth && graphPath.length) { // #1487 + attribs = { + stroke: prop[1], + 'stroke-width': lineWidth, + fill: NONE, + zIndex: 1 // #1069 + }; + if (dashStyle) { + attribs.dashstyle = dashStyle; + } else if (roundCap) { + attribs['stroke-linecap'] = attribs['stroke-linejoin'] = 'round'; + } + + series[graphKey] = series.chart.renderer.path(graphPath) + .attr(attribs) + .add(series.group) + .shadow(!i && options.shadow); + } + }); + }, + + /** + * Clip the graphs into the positive and negative coloured graphs + */ + clipNeg: function () { + var options = this.options, + chart = this.chart, + renderer = chart.renderer, + negativeColor = options.negativeColor || options.negativeFillColor, + translatedThreshold, + posAttr, + negAttr, + graph = this.graph, + area = this.area, + posClip = this.posClip, + negClip = this.negClip, + chartWidth = chart.chartWidth, + chartHeight = chart.chartHeight, + chartSizeMax = mathMax(chartWidth, chartHeight), + yAxis = this.yAxis, + above, + below; + + if (negativeColor && (graph || area)) { + translatedThreshold = mathRound(yAxis.toPixels(options.threshold || 0, true)); + if (translatedThreshold < 0) { + chartSizeMax -= translatedThreshold; // #2534 + } + above = { + x: 0, + y: 0, + width: chartSizeMax, + height: translatedThreshold + }; + below = { + x: 0, + y: translatedThreshold, + width: chartSizeMax, + height: chartSizeMax + }; + + if (chart.inverted) { + + above.height = below.y = chart.plotWidth - translatedThreshold; + if (renderer.isVML) { + above = { + x: chart.plotWidth - translatedThreshold - chart.plotLeft, + y: 0, + width: chartWidth, + height: chartHeight + }; + below = { + x: translatedThreshold + chart.plotLeft - chartWidth, + y: 0, + width: chart.plotLeft + translatedThreshold, + height: chartWidth + }; + } + } + + if (yAxis.reversed) { + posAttr = below; + negAttr = above; + } else { + posAttr = above; + negAttr = below; + } + + if (posClip) { // update + posClip.animate(posAttr); + negClip.animate(negAttr); + } else { + + this.posClip = posClip = renderer.clipRect(posAttr); + this.negClip = negClip = renderer.clipRect(negAttr); + + if (graph && this.graphNeg) { + graph.clip(posClip); + this.graphNeg.clip(negClip); + } + + if (area) { + area.clip(posClip); + this.areaNeg.clip(negClip); + } + } + } + }, + + /** + * Initialize and perform group inversion on series.group and series.markerGroup + */ + invertGroups: function () { + var series = this, + chart = series.chart; + + // Pie, go away (#1736) + if (!series.xAxis) { + return; + } + + // A fixed size is needed for inversion to work + function setInvert() { + var size = { + width: series.yAxis.len, + height: series.xAxis.len + }; + + each(['group', 'markerGroup'], function (groupName) { + if (series[groupName]) { + series[groupName].attr(size).invert(); + } + }); + } + + addEvent(chart, 'resize', setInvert); // do it on resize + addEvent(series, 'destroy', function () { + removeEvent(chart, 'resize', setInvert); + }); + + // Do it now + setInvert(); // do it now + + // On subsequent render and redraw, just do setInvert without setting up events again + series.invertGroups = setInvert; + }, + + /** + * General abstraction for creating plot groups like series.group, series.dataLabelsGroup and + * series.markerGroup. On subsequent calls, the group will only be adjusted to the updated plot size. + */ + plotGroup: function (prop, name, visibility, zIndex, parent) { + var group = this[prop], + isNew = !group; + + // Generate it on first call + if (isNew) { + this[prop] = group = this.chart.renderer.g(name) + .attr({ + visibility: visibility, + zIndex: zIndex || 0.1 // IE8 needs this + }) + .add(parent); + } + // Place it on first and subsequent (redraw) calls + group[isNew ? 'attr' : 'animate'](this.getPlotBox()); + return group; + }, + + /** + * Get the translation and scale for the plot area of this series + */ + getPlotBox: function () { + var chart = this.chart, + xAxis = this.xAxis, + yAxis = this.yAxis; + + // Swap axes for inverted (#2339) + if (chart.inverted) { + xAxis = yAxis; + yAxis = this.xAxis; + } + return { + translateX: xAxis ? xAxis.left : chart.plotLeft, + translateY: yAxis ? yAxis.top : chart.plotTop, + scaleX: 1, // #1623 + scaleY: 1 + }; + }, + + /** + * Render the graph and markers + */ + render: function () { + var series = this, + chart = series.chart, + group, + options = series.options, + animation = options.animation, + // Animation doesn't work in IE8 quirks when the group div is hidden, + // and looks bad in other oldIE + animDuration = (animation && !!series.animate && chart.renderer.isSVG && pick(animation.duration, 500)) || 0, + visibility = series.visible ? VISIBLE : HIDDEN, + zIndex = options.zIndex, + hasRendered = series.hasRendered, + chartSeriesGroup = chart.seriesGroup; + + // the group + group = series.plotGroup( + 'group', + 'series', + visibility, + zIndex, + chartSeriesGroup + ); + + series.markerGroup = series.plotGroup( + 'markerGroup', + 'markers', + visibility, + zIndex, + chartSeriesGroup + ); + + // initiate the animation + if (animDuration) { + series.animate(true); + } + + // cache attributes for shapes + series.getAttribs(); + + // SVGRenderer needs to know this before drawing elements (#1089, #1795) + group.inverted = series.isCartesian ? chart.inverted : false; + + // draw the graph if any + if (series.drawGraph) { + series.drawGraph(); + series.clipNeg(); + } + + // draw the data labels (inn pies they go before the points) + if (series.drawDataLabels) { + series.drawDataLabels(); + } + + // draw the points + if (series.visible) { + series.drawPoints(); + } + + + // draw the mouse tracking area + if (series.drawTracker && series.options.enableMouseTracking !== false) { + series.drawTracker(); + } + + // Handle inverted series and tracker groups + if (chart.inverted) { + series.invertGroups(); + } + + // Initial clipping, must be defined after inverting groups for VML + if (options.clip !== false && !series.sharedClipKey && !hasRendered) { + group.clip(chart.clipRect); + } + + // Run the animation + if (animDuration) { + series.animate(); + } + + // Call the afterAnimate function on animation complete (but don't overwrite the animation.complete option + // which should be available to the user). + if (!hasRendered) { + if (animDuration) { + series.animationTimeout = setTimeout(function () { + series.afterAnimate(); + }, animDuration); + } else { + series.afterAnimate(); + } + } + + series.isDirty = series.isDirtyData = false; // means data is in accordance with what you see + // (See #322) series.isDirty = series.isDirtyData = false; // means data is in accordance with what you see + series.hasRendered = true; + }, + + /** + * Redraw the series after an update in the axes. + */ + redraw: function () { + var series = this, + chart = series.chart, + wasDirtyData = series.isDirtyData, // cache it here as it is set to false in render, but used after + group = series.group, + xAxis = series.xAxis, + yAxis = series.yAxis; + + // reposition on resize + if (group) { + if (chart.inverted) { + group.attr({ + width: chart.plotWidth, + height: chart.plotHeight + }); + } + + group.animate({ + translateX: pick(xAxis && xAxis.left, chart.plotLeft), + translateY: pick(yAxis && yAxis.top, chart.plotTop) + }); + } + + series.translate(); + if (series.setTooltipPoints) { + series.setTooltipPoints(true); + } + series.render(); + + if (wasDirtyData) { + fireEvent(series, 'updatedData'); + } + } +}; // end Series prototype + +/** + * The class for stack items + */ +function StackItem(axis, options, isNegative, x, stackOption) { + + var inverted = axis.chart.inverted; + + this.axis = axis; + + // Tells if the stack is negative + this.isNegative = isNegative; + + // Save the options to be able to style the label + this.options = options; + + // Save the x value to be able to position the label later + this.x = x; + + // Initialize total value + this.total = null; + + // This will keep each points' extremes stored by series.index and point index + this.points = {}; + + // Save the stack option on the series configuration object, and whether to treat it as percent + this.stack = stackOption; + + // The align options and text align varies on whether the stack is negative and + // if the chart is inverted or not. + // First test the user supplied value, then use the dynamic. + this.alignOptions = { + align: options.align || (inverted ? (isNegative ? 'left' : 'right') : 'center'), + verticalAlign: options.verticalAlign || (inverted ? 'middle' : (isNegative ? 'bottom' : 'top')), + y: pick(options.y, inverted ? 4 : (isNegative ? 14 : -6)), + x: pick(options.x, inverted ? (isNegative ? -6 : 6) : 0) + }; + + this.textAlign = options.textAlign || (inverted ? (isNegative ? 'right' : 'left') : 'center'); +} + +StackItem.prototype = { + destroy: function () { + destroyObjectProperties(this, this.axis); + }, + + /** + * Renders the stack total label and adds it to the stack label group. + */ + render: function (group) { + var options = this.options, + formatOption = options.format, + str = formatOption ? + format(formatOption, this) : + options.formatter.call(this); // format the text in the label + + // Change the text to reflect the new total and set visibility to hidden in case the serie is hidden + if (this.label) { + this.label.attr({text: str, visibility: HIDDEN}); + // Create new label + } else { + this.label = + this.axis.chart.renderer.text(str, null, null, options.useHTML) // dummy positions, actual position updated with setOffset method in columnseries + .css(options.style) // apply style + .attr({ + align: this.textAlign, // fix the text-anchor + rotation: options.rotation, // rotation + visibility: HIDDEN // hidden until setOffset is called + }) + .add(group); // add to the labels-group + } + }, + + /** + * Sets the offset that the stack has from the x value and repositions the label. + */ + setOffset: function (xOffset, xWidth) { + var stackItem = this, + axis = stackItem.axis, + chart = axis.chart, + inverted = chart.inverted, + neg = this.isNegative, // special treatment is needed for negative stacks + y = axis.translate(axis.usePercentage ? 100 : this.total, 0, 0, 0, 1), // stack value translated mapped to chart coordinates + yZero = axis.translate(0), // stack origin + h = mathAbs(y - yZero), // stack height + x = chart.xAxis[0].translate(this.x) + xOffset, // stack x position + plotHeight = chart.plotHeight, + stackBox = { // this is the box for the complete stack + x: inverted ? (neg ? y : y - h) : x, + y: inverted ? plotHeight - x - xWidth : (neg ? (plotHeight - y - h) : plotHeight - y), + width: inverted ? h : xWidth, + height: inverted ? xWidth : h + }, + label = this.label, + alignAttr; + + if (label) { + label.align(this.alignOptions, null, stackBox); // align the label to the box + + // Set visibility (#678) + alignAttr = label.alignAttr; + label[this.options.crop === false || chart.isInsidePlot(alignAttr.x, alignAttr.y) ? 'show' : 'hide'](true); + } + } +}; + + +// Stacking methods defined on the Axis prototype + +/** + * Build the stacks from top down + */ +Axis.prototype.buildStacks = function () { + var series = this.series, + reversedStacks = pick(this.options.reversedStacks, true), + i = series.length; + if (!this.isXAxis) { + this.usePercentage = false; + while (i--) { + series[reversedStacks ? i : series.length - i - 1].setStackedPoints(); + } + // Loop up again to compute percent stack + if (this.usePercentage) { + for (i = 0; i < series.length; i++) { + series[i].setPercentStacks(); + } + } + } +}; + +Axis.prototype.renderStackTotals = function () { + var axis = this, + chart = axis.chart, + renderer = chart.renderer, + stacks = axis.stacks, + stackKey, + oneStack, + stackCategory, + stackTotalGroup = axis.stackTotalGroup; + + // Create a separate group for the stack total labels + if (!stackTotalGroup) { + axis.stackTotalGroup = stackTotalGroup = + renderer.g('stack-labels') + .attr({ + visibility: VISIBLE, + zIndex: 6 + }) + .add(); + } + + // plotLeft/Top will change when y axis gets wider so we need to translate the + // stackTotalGroup at every render call. See bug #506 and #516 + stackTotalGroup.translate(chart.plotLeft, chart.plotTop); + + // Render each stack total + for (stackKey in stacks) { + oneStack = stacks[stackKey]; + for (stackCategory in oneStack) { + oneStack[stackCategory].render(stackTotalGroup); + } + } +}; + + +// Stacking methods defnied for Series prototype + +/** + * Adds series' points value to corresponding stack + */ +Series.prototype.setStackedPoints = function () { + if (!this.options.stacking || (this.visible !== true && this.chart.options.chart.ignoreHiddenSeries !== false)) { + return; + } + + var series = this, + xData = series.processedXData, + yData = series.processedYData, + stackedYData = [], + yDataLength = yData.length, + seriesOptions = series.options, + threshold = seriesOptions.threshold, + stackOption = seriesOptions.stack, + stacking = seriesOptions.stacking, + stackKey = series.stackKey, + negKey = '-' + stackKey, + negStacks = series.negStacks, + yAxis = series.yAxis, + stacks = yAxis.stacks, + oldStacks = yAxis.oldStacks, + isNegative, + stack, + other, + key, + pointKey, + i, + x, + y; + + // loop over the non-null y values and read them into a local array + for (i = 0; i < yDataLength; i++) { + x = xData[i]; + y = yData[i]; + pointKey = series.index + ',' + i; + + // Read stacked values into a stack based on the x value, + // the sign of y and the stack key. Stacking is also handled for null values (#739) + isNegative = negStacks && y < threshold; + key = isNegative ? negKey : stackKey; + + // Create empty object for this stack if it doesn't exist yet + if (!stacks[key]) { + stacks[key] = {}; + } + + // Initialize StackItem for this x + if (!stacks[key][x]) { + if (oldStacks[key] && oldStacks[key][x]) { + stacks[key][x] = oldStacks[key][x]; + stacks[key][x].total = null; + } else { + stacks[key][x] = new StackItem(yAxis, yAxis.options.stackLabels, isNegative, x, stackOption); + } + } + + // If the StackItem doesn't exist, create it first + stack = stacks[key][x]; + stack.points[pointKey] = [stack.cum || 0]; + + // Add value to the stack total + if (stacking === 'percent') { + + // Percent stacked column, totals are the same for the positive and negative stacks + other = isNegative ? stackKey : negKey; + if (negStacks && stacks[other] && stacks[other][x]) { + other = stacks[other][x]; + stack.total = other.total = mathMax(other.total, stack.total) + mathAbs(y) || 0; + + // Percent stacked areas + } else { + stack.total = correctFloat(stack.total + (mathAbs(y) || 0)); + } + } else { + stack.total = correctFloat(stack.total + (y || 0)); + } + + stack.cum = (stack.cum || 0) + (y || 0); + + stack.points[pointKey].push(stack.cum); + stackedYData[i] = stack.cum; + + } + + if (stacking === 'percent') { + yAxis.usePercentage = true; + } + + this.stackedYData = stackedYData; // To be used in getExtremes + + // Reset old stacks + yAxis.oldStacks = {}; +}; + +/** + * Iterate over all stacks and compute the absolute values to percent + */ +Series.prototype.setPercentStacks = function () { + var series = this, + stackKey = series.stackKey, + stacks = series.yAxis.stacks, + processedXData = series.processedXData; + + each([stackKey, '-' + stackKey], function (key) { + var i = processedXData.length, + x, + stack, + pointExtremes, + totalFactor; + + while (i--) { + x = processedXData[i]; + stack = stacks[key] && stacks[key][x]; + pointExtremes = stack && stack.points[series.index + ',' + i]; + if (pointExtremes) { + totalFactor = stack.total ? 100 / stack.total : 0; + pointExtremes[0] = correctFloat(pointExtremes[0] * totalFactor); // Y bottom value + pointExtremes[1] = correctFloat(pointExtremes[1] * totalFactor); // Y value + series.stackedYData[i] = pointExtremes[1]; + } + } + }); +}; + +// Extend the Chart prototype for dynamic methods +extend(Chart.prototype, { + + /** + * Add a series dynamically after time + * + * @param {Object} options The config options + * @param {Boolean} redraw Whether to redraw the chart after adding. Defaults to true. + * @param {Boolean|Object} animation Whether to apply animation, and optionally animation + * configuration + * + * @return {Object} series The newly created series object + */ + addSeries: function (options, redraw, animation) { + var series, + chart = this; + + if (options) { + redraw = pick(redraw, true); // defaults to true + + fireEvent(chart, 'addSeries', { options: options }, function () { + series = chart.initSeries(options); + + chart.isDirtyLegend = true; // the series array is out of sync with the display + chart.linkSeries(); + if (redraw) { + chart.redraw(animation); + } + }); + } + + return series; + }, + + /** + * Add an axis to the chart + * @param {Object} options The axis option + * @param {Boolean} isX Whether it is an X axis or a value axis + */ + addAxis: function (options, isX, redraw, animation) { + var key = isX ? 'xAxis' : 'yAxis', + chartOptions = this.options, + axis; + + /*jslint unused: false*/ + axis = new Axis(this, merge(options, { + index: this[key].length, + isX: isX + })); + /*jslint unused: true*/ + + // Push the new axis options to the chart options + chartOptions[key] = splat(chartOptions[key] || {}); + chartOptions[key].push(options); + + if (pick(redraw, true)) { + this.redraw(animation); + } + }, + + /** + * Dim the chart and show a loading text or symbol + * @param {String} str An optional text to show in the loading label instead of the default one + */ + showLoading: function (str) { + var chart = this, + options = chart.options, + loadingDiv = chart.loadingDiv, + loadingOptions = options.loading, + setLoadingSize = function () { + if (loadingDiv) { + css(loadingDiv, { + left: chart.plotLeft + PX, + top: chart.plotTop + PX, + width: chart.plotWidth + PX, + height: chart.plotHeight + PX + }); + } + }; + + // create the layer at the first call + if (!loadingDiv) { + chart.loadingDiv = loadingDiv = createElement(DIV, { + className: PREFIX + 'loading' + }, extend(loadingOptions.style, { + zIndex: 10, + display: NONE + }), chart.container); + + chart.loadingSpan = createElement( + 'span', + null, + loadingOptions.labelStyle, + loadingDiv + ); + addEvent(chart, 'redraw', setLoadingSize); // #1080 + } + + // update text + chart.loadingSpan.innerHTML = str || options.lang.loading; + + // show it + if (!chart.loadingShown) { + css(loadingDiv, { + opacity: 0, + display: '' + }); + animate(loadingDiv, { + opacity: loadingOptions.style.opacity + }, { + duration: loadingOptions.showDuration || 0 + }); + chart.loadingShown = true; + } + setLoadingSize(); + }, + + /** + * Hide the loading layer + */ + hideLoading: function () { + var options = this.options, + loadingDiv = this.loadingDiv; + + if (loadingDiv) { + animate(loadingDiv, { + opacity: 0 + }, { + duration: options.loading.hideDuration || 100, + complete: function () { + css(loadingDiv, { display: NONE }); + } + }); + } + this.loadingShown = false; + } +}); + +// extend the Point prototype for dynamic methods +extend(Point.prototype, { + /** + * Update the point with new options (typically x/y data) and optionally redraw the series. + * + * @param {Object} options Point options as defined in the series.data array + * @param {Boolean} redraw Whether to redraw the chart or wait for an explicit call + * @param {Boolean|Object} animation Whether to apply animation, and optionally animation + * configuration + * + */ + update: function (options, redraw, animation) { + var point = this, + series = point.series, + graphic = point.graphic, + i, + data = series.data, + chart = series.chart, + seriesOptions = series.options; + + redraw = pick(redraw, true); + + // fire the event with a default handler of doing the update + point.firePointEvent('update', { options: options }, function () { + + point.applyOptions(options); + + // update visuals + if (isObject(options)) { + series.getAttribs(); + if (graphic) { + if (options && options.marker && options.marker.symbol) { + point.graphic = graphic.destroy(); + } else { + graphic.attr(point.pointAttr[point.state || '']); + } + } + if (options && options.dataLabels && point.dataLabel) { // #2468 + point.dataLabel = point.dataLabel.destroy(); + } + } + + // record changes in the parallel arrays + i = inArray(point, data); + series.updateParallelArrays(point, i); + + seriesOptions.data[i] = point.options; + + // redraw + series.isDirty = series.isDirtyData = true; + if (!series.fixedBox && series.hasCartesianSeries) { // #1906, #2320 + chart.isDirtyBox = true; + } + + if (seriesOptions.legendType === 'point') { // #1831, #1885 + chart.legend.destroyItem(point); + } + if (redraw) { + chart.redraw(animation); + } + }); + }, + + /** + * Remove a point and optionally redraw the series and if necessary the axes + * @param {Boolean} redraw Whether to redraw the chart or wait for an explicit call + * @param {Boolean|Object} animation Whether to apply animation, and optionally animation + * configuration + */ + remove: function (redraw, animation) { + var point = this, + series = point.series, + points = series.points, + chart = series.chart, + i, + data = series.data; + + setAnimation(animation, chart); + redraw = pick(redraw, true); + + // fire the event with a default handler of removing the point + point.firePointEvent('remove', null, function () { + + // splice all the parallel arrays + i = inArray(point, data); + if (data.length === points.length) { + points.splice(i, 1); + } + data.splice(i, 1); + series.options.data.splice(i, 1); + series.updateParallelArrays(point, 'splice', i, 1); + + point.destroy(); + + // redraw + series.isDirty = true; + series.isDirtyData = true; + if (redraw) { + chart.redraw(); + } + }); + } +}); + +// Extend the series prototype for dynamic methods +extend(Series.prototype, { + /** + * Add a point dynamically after chart load time + * @param {Object} options Point options as given in series.data + * @param {Boolean} redraw Whether to redraw the chart or wait for an explicit call + * @param {Boolean} shift If shift is true, a point is shifted off the start + * of the series as one is appended to the end. + * @param {Boolean|Object} animation Whether to apply animation, and optionally animation + * configuration + */ + addPoint: function (options, redraw, shift, animation) { + var series = this, + seriesOptions = series.options, + data = series.data, + graph = series.graph, + area = series.area, + chart = series.chart, + names = series.xAxis && series.xAxis.names, + currentShift = (graph && graph.shift) || 0, + dataOptions = seriesOptions.data, + point, + isInTheMiddle, + xData = series.xData, + x, + i; + + setAnimation(animation, chart); + + // Make graph animate sideways + if (shift) { + each([graph, area, series.graphNeg, series.areaNeg], function (shape) { + if (shape) { + shape.shift = currentShift + 1; + } + }); + } + if (area) { + area.isArea = true; // needed in animation, both with and without shift + } + + // Optional redraw, defaults to true + redraw = pick(redraw, true); + + // Get options and push the point to xData, yData and series.options. In series.generatePoints + // the Point instance will be created on demand and pushed to the series.data array. + point = { series: series }; + series.pointClass.prototype.applyOptions.apply(point, [options]); + x = point.x; + + // Get the insertion point + i = xData.length; + if (series.requireSorting && x < xData[i - 1]) { + isInTheMiddle = true; + while (i && xData[i - 1] > x) { + i--; + } + } + + series.updateParallelArrays(point, 'splice', i, 0, 0); // insert undefined item + series.updateParallelArrays(point, i); // update it + + if (names) { + names[x] = point.name; + } + dataOptions.splice(i, 0, options); + + if (isInTheMiddle) { + series.data.splice(i, 0, null); + series.processData(); + } + + // Generate points to be added to the legend (#1329) + if (seriesOptions.legendType === 'point') { + series.generatePoints(); + } + + // Shift the first point off the parallel arrays + // todo: consider series.removePoint(i) method + if (shift) { + if (data[0] && data[0].remove) { + data[0].remove(false); + } else { + data.shift(); + series.updateParallelArrays(point, 'shift'); + + dataOptions.shift(); + } + } + + // redraw + series.isDirty = true; + series.isDirtyData = true; + if (redraw) { + series.getAttribs(); // #1937 + chart.redraw(); + } + }, + + /** + * Remove a series and optionally redraw the chart + * + * @param {Boolean} redraw Whether to redraw the chart or wait for an explicit call + * @param {Boolean|Object} animation Whether to apply animation, and optionally animation + * configuration + */ + + remove: function (redraw, animation) { + var series = this, + chart = series.chart; + redraw = pick(redraw, true); + + if (!series.isRemoving) { /* prevent triggering native event in jQuery + (calling the remove function from the remove event) */ + series.isRemoving = true; + + // fire the event with a default handler of removing the point + fireEvent(series, 'remove', null, function () { + + + // destroy elements + series.destroy(); + + + // redraw + chart.isDirtyLegend = chart.isDirtyBox = true; + chart.linkSeries(); + + if (redraw) { + chart.redraw(animation); + } + }); + + } + series.isRemoving = false; + }, + + /** + * Update the series with a new set of options + */ + update: function (newOptions, redraw) { + var series = this, + chart = this.chart, + // must use user options when changing type because this.options is merged + // in with type specific plotOptions + oldOptions = this.userOptions, + oldType = this.type, + proto = seriesTypes[oldType].prototype, + preserve = ['group', 'markerGroup', 'dataLabelsGroup'], + n; + + // Make sure groups are not destroyed (#3094) + each(preserve, function (prop) { + preserve[prop] = series[prop]; + delete series[prop]; + }); + + // Do the merge, with some forced options + newOptions = merge(oldOptions, { + animation: false, + index: this.index, + pointStart: this.xData[0] // when updating after addPoint + }, { data: this.options.data }, newOptions); + + // Destroy the series and reinsert methods from the type prototype + this.remove(false); + for (n in proto) { // Overwrite series-type specific methods (#2270) + if (proto.hasOwnProperty(n)) { + this[n] = UNDEFINED; + } + } + extend(this, seriesTypes[newOptions.type || oldType].prototype); + + // Re-register groups (#3094) + each(preserve, function (prop) { + series[prop] = preserve[prop]; + }); + + + this.init(chart, newOptions); + chart.linkSeries(); // Links are lost in this.remove (#3028) + if (pick(redraw, true)) { + chart.redraw(false); + } + } +}); + +// Extend the Axis.prototype for dynamic methods +extend(Axis.prototype, { + + /** + * Update the axis with a new options structure + */ + update: function (newOptions, redraw) { + var chart = this.chart; + + newOptions = chart.options[this.coll][this.options.index] = merge(this.userOptions, newOptions); + + this.destroy(true); + this._addedPlotLB = UNDEFINED; // #1611, #2887 + + this.init(chart, extend(newOptions, { events: UNDEFINED })); + + chart.isDirtyBox = true; + if (pick(redraw, true)) { + chart.redraw(); + } + }, + + /** + * Remove the axis from the chart + */ + remove: function (redraw) { + var chart = this.chart, + key = this.coll, // xAxis or yAxis + axisSeries = this.series, + i = axisSeries.length; + + // Remove associated series (#2687) + while (i--) { + if (axisSeries[i]) { + axisSeries[i].remove(false); + } + } + + // Remove the axis + erase(chart.axes, this); + erase(chart[key], this); + chart.options[key].splice(this.options.index, 1); + each(chart[key], function (axis, i) { // Re-index, #1706 + axis.options.index = i; + }); + this.destroy(); + chart.isDirtyBox = true; + + if (pick(redraw, true)) { + chart.redraw(); + } + }, + + /** + * Update the axis title by options + */ + setTitle: function (newTitleOptions, redraw) { + this.update({ title: newTitleOptions }, redraw); + }, + + /** + * Set new axis categories and optionally redraw + * @param {Array} categories + * @param {Boolean} redraw + */ + setCategories: function (categories, redraw) { + this.update({ categories: categories }, redraw); + } + +}); + + +/** + * LineSeries object + */ +var LineSeries = extendClass(Series); +seriesTypes.line = LineSeries; + +/** + * Set the default options for area + */ +defaultPlotOptions.area = merge(defaultSeriesOptions, { + threshold: 0 + // trackByArea: false, + // lineColor: null, // overrides color, but lets fillColor be unaltered + // fillOpacity: 0.75, + // fillColor: null +}); + +/** + * AreaSeries object + */ +var AreaSeries = extendClass(Series, { + type: 'area', + /** + * For stacks, don't split segments on null values. Instead, draw null values with + * no marker. Also insert dummy points for any X position that exists in other series + * in the stack. + */ + getSegments: function () { + var series = this, + segments = [], + segment = [], + keys = [], + xAxis = this.xAxis, + yAxis = this.yAxis, + stack = yAxis.stacks[this.stackKey], + pointMap = {}, + plotX, + plotY, + points = this.points, + connectNulls = this.options.connectNulls, + i, + x; + + if (this.options.stacking && !this.cropped) { // cropped causes artefacts in Stock, and perf issue + // Create a map where we can quickly look up the points by their X value. + for (i = 0; i < points.length; i++) { + pointMap[points[i].x] = points[i]; + } + + // Sort the keys (#1651) + for (x in stack) { + if (stack[x].total !== null) { // nulled after switching between grouping and not (#1651, #2336) + keys.push(+x); + } + } + keys.sort(function (a, b) { + return a - b; + }); + + each(keys, function (x) { + var y = 0, + stackPoint; + + if (connectNulls && (!pointMap[x] || pointMap[x].y === null)) { // #1836 + return; + + // The point exists, push it to the segment + } else if (pointMap[x]) { + segment.push(pointMap[x]); + + // There is no point for this X value in this series, so we + // insert a dummy point in order for the areas to be drawn + // correctly. + } else { + + // Loop down the stack to find the series below this one that has + // a value (#1991) + for (i = series.index; i <= yAxis.series.length; i++) { + stackPoint = stack[x].points[i + ',' + x]; + if (stackPoint) { + y = stackPoint[1]; + break; + } + } + + plotX = xAxis.translate(x); + plotY = yAxis.toPixels(y, true); + segment.push({ + y: null, + plotX: plotX, + clientX: plotX, + plotY: plotY, + yBottom: plotY, + onMouseOver: noop + }); + } + }); + + if (segment.length) { + segments.push(segment); + } + + } else { + Series.prototype.getSegments.call(this); + segments = this.segments; + } + + this.segments = segments; + }, + + /** + * Extend the base Series getSegmentPath method by adding the path for the area. + * This path is pushed to the series.areaPath property. + */ + getSegmentPath: function (segment) { + + var segmentPath = Series.prototype.getSegmentPath.call(this, segment), // call base method + areaSegmentPath = [].concat(segmentPath), // work on a copy for the area path + i, + options = this.options, + segLength = segmentPath.length, + translatedThreshold = this.yAxis.getThreshold(options.threshold), // #2181 + yBottom; + + if (segLength === 3) { // for animation from 1 to two points + areaSegmentPath.push(L, segmentPath[1], segmentPath[2]); + } + if (options.stacking && !this.closedStacks) { + + // Follow stack back. Todo: implement areaspline. A general solution could be to + // reverse the entire graphPath of the previous series, though may be hard with + // splines and with series with different extremes + for (i = segment.length - 1; i >= 0; i--) { + + yBottom = pick(segment[i].yBottom, translatedThreshold); + + // step line? + if (i < segment.length - 1 && options.step) { + areaSegmentPath.push(segment[i + 1].plotX, yBottom); + } + + areaSegmentPath.push(segment[i].plotX, yBottom); + } + + } else { // follow zero line back + this.closeSegment(areaSegmentPath, segment, translatedThreshold); + } + this.areaPath = this.areaPath.concat(areaSegmentPath); + return segmentPath; + }, + + /** + * Extendable method to close the segment path of an area. This is overridden in polar + * charts. + */ + closeSegment: function (path, segment, translatedThreshold) { + path.push( + L, + segment[segment.length - 1].plotX, + translatedThreshold, + L, + segment[0].plotX, + translatedThreshold + ); + }, + + /** + * Draw the graph and the underlying area. This method calls the Series base + * function and adds the area. The areaPath is calculated in the getSegmentPath + * method called from Series.prototype.drawGraph. + */ + drawGraph: function () { + + // Define or reset areaPath + this.areaPath = []; + + // Call the base method + Series.prototype.drawGraph.apply(this); + + // Define local variables + var series = this, + areaPath = this.areaPath, + options = this.options, + negativeColor = options.negativeColor, + negativeFillColor = options.negativeFillColor, + props = [['area', this.color, options.fillColor]]; // area name, main color, fill color + + if (negativeColor || negativeFillColor) { + props.push(['areaNeg', negativeColor, negativeFillColor]); + } + + each(props, function (prop) { + var areaKey = prop[0], + area = series[areaKey]; + + // Create or update the area + if (area) { // update + area.animate({ d: areaPath }); + + } else { // create + series[areaKey] = series.chart.renderer.path(areaPath) + .attr({ + fill: pick( + prop[2], + Color(prop[1]).setOpacity(pick(options.fillOpacity, 0.75)).get() + ), + zIndex: 0 // #1069 + }).add(series.group); + } + }); + }, + + drawLegendSymbol: LegendSymbolMixin.drawRectangle +}); + +seriesTypes.area = AreaSeries; +/** + * Set the default options for spline + */ +defaultPlotOptions.spline = merge(defaultSeriesOptions); + +/** + * SplineSeries object + */ +var SplineSeries = extendClass(Series, { + type: 'spline', + + /** + * Get the spline segment from a given point's previous neighbour to the given point + */ + getPointSpline: function (segment, point, i) { + var smoothing = 1.5, // 1 means control points midway between points, 2 means 1/3 from the point, 3 is 1/4 etc + denom = smoothing + 1, + plotX = point.plotX, + plotY = point.plotY, + lastPoint = segment[i - 1], + nextPoint = segment[i + 1], + leftContX, + leftContY, + rightContX, + rightContY, + ret; + + // find control points + if (lastPoint && nextPoint) { + + var lastX = lastPoint.plotX, + lastY = lastPoint.plotY, + nextX = nextPoint.plotX, + nextY = nextPoint.plotY, + correction; + + leftContX = (smoothing * plotX + lastX) / denom; + leftContY = (smoothing * plotY + lastY) / denom; + rightContX = (smoothing * plotX + nextX) / denom; + rightContY = (smoothing * plotY + nextY) / denom; + + // have the two control points make a straight line through main point + correction = ((rightContY - leftContY) * (rightContX - plotX)) / + (rightContX - leftContX) + plotY - rightContY; + + leftContY += correction; + rightContY += correction; + + // to prevent false extremes, check that control points are between + // neighbouring points' y values + if (leftContY > lastY && leftContY > plotY) { + leftContY = mathMax(lastY, plotY); + rightContY = 2 * plotY - leftContY; // mirror of left control point + } else if (leftContY < lastY && leftContY < plotY) { + leftContY = mathMin(lastY, plotY); + rightContY = 2 * plotY - leftContY; + } + if (rightContY > nextY && rightContY > plotY) { + rightContY = mathMax(nextY, plotY); + leftContY = 2 * plotY - rightContY; + } else if (rightContY < nextY && rightContY < plotY) { + rightContY = mathMin(nextY, plotY); + leftContY = 2 * plotY - rightContY; + } + + // record for drawing in next point + point.rightContX = rightContX; + point.rightContY = rightContY; + + } + + // Visualize control points for debugging + /* + if (leftContX) { + this.chart.renderer.circle(leftContX + this.chart.plotLeft, leftContY + this.chart.plotTop, 2) + .attr({ + stroke: 'red', + 'stroke-width': 1, + fill: 'none' + }) + .add(); + this.chart.renderer.path(['M', leftContX + this.chart.plotLeft, leftContY + this.chart.plotTop, + 'L', plotX + this.chart.plotLeft, plotY + this.chart.plotTop]) + .attr({ + stroke: 'red', + 'stroke-width': 1 + }) + .add(); + this.chart.renderer.circle(rightContX + this.chart.plotLeft, rightContY + this.chart.plotTop, 2) + .attr({ + stroke: 'green', + 'stroke-width': 1, + fill: 'none' + }) + .add(); + this.chart.renderer.path(['M', rightContX + this.chart.plotLeft, rightContY + this.chart.plotTop, + 'L', plotX + this.chart.plotLeft, plotY + this.chart.plotTop]) + .attr({ + stroke: 'green', + 'stroke-width': 1 + }) + .add(); + } + */ + + // moveTo or lineTo + if (!i) { + ret = [M, plotX, plotY]; + } else { // curve from last point to this + ret = [ + 'C', + lastPoint.rightContX || lastPoint.plotX, + lastPoint.rightContY || lastPoint.plotY, + leftContX || plotX, + leftContY || plotY, + plotX, + plotY + ]; + lastPoint.rightContX = lastPoint.rightContY = null; // reset for updating series later + } + return ret; + } +}); +seriesTypes.spline = SplineSeries; + +/** + * Set the default options for areaspline + */ +defaultPlotOptions.areaspline = merge(defaultPlotOptions.area); + +/** + * AreaSplineSeries object + */ +var areaProto = AreaSeries.prototype, + AreaSplineSeries = extendClass(SplineSeries, { + type: 'areaspline', + closedStacks: true, // instead of following the previous graph back, follow the threshold back + + // Mix in methods from the area series + getSegmentPath: areaProto.getSegmentPath, + closeSegment: areaProto.closeSegment, + drawGraph: areaProto.drawGraph, + drawLegendSymbol: LegendSymbolMixin.drawRectangle + }); + +seriesTypes.areaspline = AreaSplineSeries; + +/** + * Set the default options for column + */ +defaultPlotOptions.column = merge(defaultSeriesOptions, { + borderColor: '#FFFFFF', + //borderWidth: 1, + borderRadius: 0, + //colorByPoint: undefined, + groupPadding: 0.2, + //grouping: true, + marker: null, // point options are specified in the base options + pointPadding: 0.1, + //pointWidth: null, + minPointLength: 0, + cropThreshold: 50, // when there are more points, they will not animate out of the chart on xAxis.setExtremes + pointRange: null, // null means auto, meaning 1 in a categorized axis and least distance between points if not categories + states: { + hover: { + brightness: 0.1, + shadow: false, + halo: false + }, + select: { + color: '#C0C0C0', + borderColor: '#000000', + shadow: false + } + }, + dataLabels: { + align: null, // auto + verticalAlign: null, // auto + y: null + }, + stickyTracking: false, + tooltip: { + distance: 6 + }, + threshold: 0 +}); + +/** + * ColumnSeries object + */ +var ColumnSeries = extendClass(Series, { + type: 'column', + pointAttrToOptions: { // mapping between SVG attributes and the corresponding options + stroke: 'borderColor', + fill: 'color', + r: 'borderRadius' + }, + cropShoulder: 0, + trackerGroups: ['group', 'dataLabelsGroup'], + negStacks: true, // use separate negative stacks, unlike area stacks where a negative + // point is substracted from previous (#1910) + + /** + * Initialize the series + */ + init: function () { + Series.prototype.init.apply(this, arguments); + + var series = this, + chart = series.chart; + + // if the series is added dynamically, force redraw of other + // series affected by a new column + if (chart.hasRendered) { + each(chart.series, function (otherSeries) { + if (otherSeries.type === series.type) { + otherSeries.isDirty = true; + } + }); + } + }, + + /** + * Return the width and x offset of the columns adjusted for grouping, groupPadding, pointPadding, + * pointWidth etc. + */ + getColumnMetrics: function () { + + var series = this, + options = series.options, + xAxis = series.xAxis, + yAxis = series.yAxis, + reversedXAxis = xAxis.reversed, + stackKey, + stackGroups = {}, + columnIndex, + columnCount = 0; + + // Get the total number of column type series. + // This is called on every series. Consider moving this logic to a + // chart.orderStacks() function and call it on init, addSeries and removeSeries + if (options.grouping === false) { + columnCount = 1; + } else { + each(series.chart.series, function (otherSeries) { + var otherOptions = otherSeries.options, + otherYAxis = otherSeries.yAxis; + if (otherSeries.type === series.type && otherSeries.visible && + yAxis.len === otherYAxis.len && yAxis.pos === otherYAxis.pos) { // #642, #2086 + if (otherOptions.stacking) { + stackKey = otherSeries.stackKey; + if (stackGroups[stackKey] === UNDEFINED) { + stackGroups[stackKey] = columnCount++; + } + columnIndex = stackGroups[stackKey]; + } else if (otherOptions.grouping !== false) { // #1162 + columnIndex = columnCount++; + } + otherSeries.columnIndex = columnIndex; + } + }); + } + + var categoryWidth = mathMin( + mathAbs(xAxis.transA) * (xAxis.ordinalSlope || options.pointRange || xAxis.closestPointRange || xAxis.tickInterval || 1), // #2610 + xAxis.len // #1535 + ), + groupPadding = categoryWidth * options.groupPadding, + groupWidth = categoryWidth - 2 * groupPadding, + pointOffsetWidth = groupWidth / columnCount, + optionPointWidth = options.pointWidth, + pointPadding = defined(optionPointWidth) ? (pointOffsetWidth - optionPointWidth) / 2 : + pointOffsetWidth * options.pointPadding, + pointWidth = pick(optionPointWidth, pointOffsetWidth - 2 * pointPadding), // exact point width, used in polar charts + colIndex = (reversedXAxis ? + columnCount - (series.columnIndex || 0) : // #1251 + series.columnIndex) || 0, + pointXOffset = pointPadding + (groupPadding + colIndex * + pointOffsetWidth - (categoryWidth / 2)) * + (reversedXAxis ? -1 : 1); + + // Save it for reading in linked series (Error bars particularly) + return (series.columnMetrics = { + width: pointWidth, + offset: pointXOffset + }); + + }, + + /** + * Translate each point to the plot area coordinate system and find shape positions + */ + translate: function () { + var series = this, + chart = series.chart, + options = series.options, + borderWidth = series.borderWidth = pick( + options.borderWidth, + series.activePointCount > 0.5 * series.xAxis.len ? 0 : 1 + ), + yAxis = series.yAxis, + threshold = options.threshold, + translatedThreshold = series.translatedThreshold = yAxis.getThreshold(threshold), + minPointLength = pick(options.minPointLength, 5), + metrics = series.getColumnMetrics(), + pointWidth = metrics.width, + seriesBarW = series.barW = mathMax(pointWidth, 1 + 2 * borderWidth), // postprocessed for border width + pointXOffset = series.pointXOffset = metrics.offset, + xCrisp = -(borderWidth % 2 ? 0.5 : 0), + yCrisp = borderWidth % 2 ? 0.5 : 1; + + if (chart.renderer.isVML && chart.inverted) { + yCrisp += 1; + } + + // When the pointPadding is 0, we want the columns to be packed tightly, so we allow individual + // columns to have individual sizes. When pointPadding is greater, we strive for equal-width + // columns (#2694). + if (options.pointPadding) { + seriesBarW = mathCeil(seriesBarW); + } + + Series.prototype.translate.apply(series); + + // Record the new values + each(series.points, function (point) { + var yBottom = pick(point.yBottom, translatedThreshold), + plotY = mathMin(mathMax(-999 - yBottom, point.plotY), yAxis.len + 999 + yBottom), // Don't draw too far outside plot area (#1303, #2241) + barX = point.plotX + pointXOffset, + barW = seriesBarW, + barY = mathMin(plotY, yBottom), + right, + bottom, + fromTop, + barH = mathMax(plotY, yBottom) - barY; + + // Handle options.minPointLength + if (mathAbs(barH) < minPointLength) { + if (minPointLength) { + barH = minPointLength; + barY = + mathRound(mathAbs(barY - translatedThreshold) > minPointLength ? // stacked + yBottom - minPointLength : // keep position + translatedThreshold - (yAxis.translate(point.y, 0, 1, 0, 1) <= translatedThreshold ? minPointLength : 0)); // use exact yAxis.translation (#1485) + } + } + + // Cache for access in polar + point.barX = barX; + point.pointWidth = pointWidth; + + // Fix the tooltip on center of grouped columns (#1216) + point.tooltipPos = chart.inverted ? [yAxis.len - plotY, series.xAxis.len - barX - barW / 2] : [barX + barW / 2, plotY]; + + // Round off to obtain crisp edges and avoid overlapping with neighbours (#2694) + right = mathRound(barX + barW) + xCrisp; + barX = mathRound(barX) + xCrisp; + barW = right - barX; + + fromTop = mathAbs(barY) < 0.5; + bottom = mathRound(barY + barH) + yCrisp; + barY = mathRound(barY) + yCrisp; + barH = bottom - barY; + + // Top edges are exceptions + if (fromTop) { + barY -= 1; + barH += 1; + } + + // Register shape type and arguments to be used in drawPoints + point.shapeType = 'rect'; + point.shapeArgs = { + x: barX, + y: barY, + width: barW, + height: barH + }; + + }); + + }, + + getSymbol: noop, + + /** + * Use a solid rectangle like the area series types + */ + drawLegendSymbol: LegendSymbolMixin.drawRectangle, + + + /** + * Columns have no graph + */ + drawGraph: noop, + + /** + * Draw the columns. For bars, the series.group is rotated, so the same coordinates + * apply for columns and bars. This method is inherited by scatter series. + * + */ + drawPoints: function () { + var series = this, + chart = this.chart, + options = series.options, + renderer = chart.renderer, + animationLimit = options.animationLimit || 250, + shapeArgs, + pointAttr; + + // draw the columns + each(series.points, function (point) { + var plotY = point.plotY, + graphic = point.graphic, + borderAttr; + + if (plotY !== UNDEFINED && !isNaN(plotY) && point.y !== null) { + shapeArgs = point.shapeArgs; + + borderAttr = defined(series.borderWidth) ? { + 'stroke-width': series.borderWidth + } : {}; + + pointAttr = point.pointAttr[point.selected ? SELECT_STATE : NORMAL_STATE] || series.pointAttr[NORMAL_STATE]; + + if (graphic) { // update + stop(graphic); + graphic.attr(borderAttr)[chart.pointCount < animationLimit ? 'animate' : 'attr'](merge(shapeArgs)); + + } else { + point.graphic = graphic = renderer[point.shapeType](shapeArgs) + .attr(pointAttr) + .attr(borderAttr) + .add(series.group) + .shadow(options.shadow, null, options.stacking && !options.borderRadius); + } + + } else if (graphic) { + point.graphic = graphic.destroy(); // #1269 + } + }); + }, + + /** + * Animate the column heights one by one from zero + * @param {Boolean} init Whether to initialize the animation or run it + */ + animate: function (init) { + var series = this, + yAxis = this.yAxis, + options = series.options, + inverted = this.chart.inverted, + attr = {}, + translatedThreshold; + + if (hasSVG) { // VML is too slow anyway + if (init) { + attr.scaleY = 0.001; + translatedThreshold = mathMin(yAxis.pos + yAxis.len, mathMax(yAxis.pos, yAxis.toPixels(options.threshold))); + if (inverted) { + attr.translateX = translatedThreshold - yAxis.len; + } else { + attr.translateY = translatedThreshold; + } + series.group.attr(attr); + + } else { // run the animation + + attr.scaleY = 1; + attr[inverted ? 'translateX' : 'translateY'] = yAxis.pos; + series.group.animate(attr, series.options.animation); + + // delete this function to allow it only once + series.animate = null; + } + } + }, + + /** + * Remove this series from the chart + */ + remove: function () { + var series = this, + chart = series.chart; + + // column and bar series affects other series of the same type + // as they are either stacked or grouped + if (chart.hasRendered) { + each(chart.series, function (otherSeries) { + if (otherSeries.type === series.type) { + otherSeries.isDirty = true; + } + }); + } + + Series.prototype.remove.apply(series, arguments); + } +}); +seriesTypes.column = ColumnSeries; +/** + * Set the default options for bar + */ +defaultPlotOptions.bar = merge(defaultPlotOptions.column); +/** + * The Bar series class + */ +var BarSeries = extendClass(ColumnSeries, { + type: 'bar', + inverted: true +}); +seriesTypes.bar = BarSeries; + +/** + * Set the default options for scatter + */ +defaultPlotOptions.scatter = merge(defaultSeriesOptions, { + lineWidth: 0, + tooltip: { + headerFormat: '\u25CF {series.name}
', + pointFormat: 'x: {point.x}
y: {point.y}
' + }, + stickyTracking: false +}); + +/** + * The scatter series class + */ +var ScatterSeries = extendClass(Series, { + type: 'scatter', + sorted: false, + requireSorting: false, + noSharedTooltip: true, + trackerGroups: ['markerGroup', 'dataLabelsGroup'], + takeOrdinalPosition: false, // #2342 + singularTooltips: true, + drawGraph: function () { + if (this.options.lineWidth) { + Series.prototype.drawGraph.call(this); + } + } +}); + +seriesTypes.scatter = ScatterSeries; + +/** + * Set the default options for pie + */ +defaultPlotOptions.pie = merge(defaultSeriesOptions, { + borderColor: '#FFFFFF', + borderWidth: 1, + center: [null, null], + clip: false, + colorByPoint: true, // always true for pies + dataLabels: { + // align: null, + // connectorWidth: 1, + // connectorColor: point.color, + // connectorPadding: 5, + distance: 30, + enabled: true, + formatter: function () { // #2945 + return this.point.name; + } + // softConnector: true, + //y: 0 + }, + ignoreHiddenPoint: true, + //innerSize: 0, + legendType: 'point', + marker: null, // point options are specified in the base options + size: null, + showInLegend: false, + slicedOffset: 10, + states: { + hover: { + brightness: 0.1, + shadow: false + } + }, + stickyTracking: false, + tooltip: { + followPointer: true + } +}); + +/** + * Extended point object for pies + */ +var PiePoint = extendClass(Point, { + /** + * Initiate the pie slice + */ + init: function () { + + Point.prototype.init.apply(this, arguments); + + var point = this, + toggleSlice; + + // Disallow negative values (#1530) + if (point.y < 0) { + point.y = null; + } + + //visible: options.visible !== false, + extend(point, { + visible: point.visible !== false, + name: pick(point.name, 'Slice') + }); + + // add event listener for select + toggleSlice = function (e) { + point.slice(e.type === 'select'); + }; + addEvent(point, 'select', toggleSlice); + addEvent(point, 'unselect', toggleSlice); + + return point; + }, + + /** + * Toggle the visibility of the pie slice + * @param {Boolean} vis Whether to show the slice or not. If undefined, the + * visibility is toggled + */ + setVisible: function (vis) { + var point = this, + series = point.series, + chart = series.chart; + + // if called without an argument, toggle visibility + point.visible = point.options.visible = vis = vis === UNDEFINED ? !point.visible : vis; + series.options.data[inArray(point, series.data)] = point.options; // update userOptions.data + + // Show and hide associated elements + each(['graphic', 'dataLabel', 'connector', 'shadowGroup'], function (key) { + if (point[key]) { + point[key][vis ? 'show' : 'hide'](true); + } + }); + + if (point.legendItem) { + chart.legend.colorizeItem(point, vis); + } + + // Handle ignore hidden slices + if (!series.isDirty && series.options.ignoreHiddenPoint) { + series.isDirty = true; + chart.redraw(); + } + }, + + /** + * Set or toggle whether the slice is cut out from the pie + * @param {Boolean} sliced When undefined, the slice state is toggled + * @param {Boolean} redraw Whether to redraw the chart. True by default. + */ + slice: function (sliced, redraw, animation) { + var point = this, + series = point.series, + chart = series.chart, + translation; + + setAnimation(animation, chart); + + // redraw is true by default + redraw = pick(redraw, true); + + // if called without an argument, toggle + point.sliced = point.options.sliced = sliced = defined(sliced) ? sliced : !point.sliced; + series.options.data[inArray(point, series.data)] = point.options; // update userOptions.data + + translation = sliced ? point.slicedTranslation : { + translateX: 0, + translateY: 0 + }; + + point.graphic.animate(translation); + + if (point.shadowGroup) { + point.shadowGroup.animate(translation); + } + + }, + + haloPath: function (size) { + var shapeArgs = this.shapeArgs, + chart = this.series.chart; + + return this.sliced || !this.visible ? [] : this.series.chart.renderer.symbols.arc(chart.plotLeft + shapeArgs.x, chart.plotTop + shapeArgs.y, shapeArgs.r + size, shapeArgs.r + size, { + innerR: this.shapeArgs.r, + start: shapeArgs.start, + end: shapeArgs.end + }); + } +}); + +/** + * The Pie series class + */ +var PieSeries = { + type: 'pie', + isCartesian: false, + pointClass: PiePoint, + requireSorting: false, + noSharedTooltip: true, + trackerGroups: ['group', 'dataLabelsGroup'], + axisTypes: [], + pointAttrToOptions: { // mapping between SVG attributes and the corresponding options + stroke: 'borderColor', + 'stroke-width': 'borderWidth', + fill: 'color' + }, + singularTooltips: true, + + /** + * Pies have one color each point + */ + getColor: noop, + + /** + * Animate the pies in + */ + animate: function (init) { + var series = this, + points = series.points, + startAngleRad = series.startAngleRad; + + if (!init) { + each(points, function (point) { + var graphic = point.graphic, + args = point.shapeArgs; + + if (graphic) { + // start values + graphic.attr({ + r: series.center[3] / 2, // animate from inner radius (#779) + start: startAngleRad, + end: startAngleRad + }); + + // animate + graphic.animate({ + r: args.r, + start: args.start, + end: args.end + }, series.options.animation); + } + }); + + // delete this function to allow it only once + series.animate = null; + } + }, + + /** + * Extend the basic setData method by running processData and generatePoints immediately, + * in order to access the points from the legend. + */ + setData: function (data, redraw, animation, updatePoints) { + Series.prototype.setData.call(this, data, false, animation, updatePoints); + this.processData(); + this.generatePoints(); + if (pick(redraw, true)) { + this.chart.redraw(animation); + } + }, + + /** + * Extend the generatePoints method by adding total and percentage properties to each point + */ + generatePoints: function () { + var i, + total = 0, + points, + len, + point, + ignoreHiddenPoint = this.options.ignoreHiddenPoint; + + Series.prototype.generatePoints.call(this); + + // Populate local vars + points = this.points; + len = points.length; + + // Get the total sum + for (i = 0; i < len; i++) { + point = points[i]; + total += (ignoreHiddenPoint && !point.visible) ? 0 : point.y; + } + this.total = total; + + // Set each point's properties + for (i = 0; i < len; i++) { + point = points[i]; + point.percentage = total > 0 ? (point.y / total) * 100 : 0; + point.total = total; + } + + }, + + /** + * Do translation for pie slices + */ + translate: function (positions) { + this.generatePoints(); + + var series = this, + cumulative = 0, + precision = 1000, // issue #172 + options = series.options, + slicedOffset = options.slicedOffset, + connectorOffset = slicedOffset + options.borderWidth, + start, + end, + angle, + startAngle = options.startAngle || 0, + startAngleRad = series.startAngleRad = mathPI / 180 * (startAngle - 90), + endAngleRad = series.endAngleRad = mathPI / 180 * ((pick(options.endAngle, startAngle + 360)) - 90), + circ = endAngleRad - startAngleRad, //2 * mathPI, + points = series.points, + radiusX, // the x component of the radius vector for a given point + radiusY, + labelDistance = options.dataLabels.distance, + ignoreHiddenPoint = options.ignoreHiddenPoint, + i, + len = points.length, + point; + + // Get positions - either an integer or a percentage string must be given. + // If positions are passed as a parameter, we're in a recursive loop for adjusting + // space for data labels. + if (!positions) { + series.center = positions = series.getCenter(); + } + + // utility for getting the x value from a given y, used for anticollision logic in data labels + series.getX = function (y, left) { + + angle = math.asin(mathMin((y - positions[1]) / (positions[2] / 2 + labelDistance), 1)); + + return positions[0] + + (left ? -1 : 1) * + (mathCos(angle) * (positions[2] / 2 + labelDistance)); + }; + + // Calculate the geometry for each point + for (i = 0; i < len; i++) { + + point = points[i]; + + // set start and end angle + start = startAngleRad + (cumulative * circ); + if (!ignoreHiddenPoint || point.visible) { + cumulative += point.percentage / 100; + } + end = startAngleRad + (cumulative * circ); + + // set the shape + point.shapeType = 'arc'; + point.shapeArgs = { + x: positions[0], + y: positions[1], + r: positions[2] / 2, + innerR: positions[3] / 2, + start: mathRound(start * precision) / precision, + end: mathRound(end * precision) / precision + }; + + // The angle must stay within -90 and 270 (#2645) + angle = (end + start) / 2; + if (angle > 1.5 * mathPI) { + angle -= 2 * mathPI; + } else if (angle < -mathPI / 2) { + angle += 2 * mathPI; + } + + // Center for the sliced out slice + point.slicedTranslation = { + translateX: mathRound(mathCos(angle) * slicedOffset), + translateY: mathRound(mathSin(angle) * slicedOffset) + }; + + // set the anchor point for tooltips + radiusX = mathCos(angle) * positions[2] / 2; + radiusY = mathSin(angle) * positions[2] / 2; + point.tooltipPos = [ + positions[0] + radiusX * 0.7, + positions[1] + radiusY * 0.7 + ]; + + point.half = angle < -mathPI / 2 || angle > mathPI / 2 ? 1 : 0; + point.angle = angle; + + // set the anchor point for data labels + connectorOffset = mathMin(connectorOffset, labelDistance / 2); // #1678 + point.labelPos = [ + positions[0] + radiusX + mathCos(angle) * labelDistance, // first break of connector + positions[1] + radiusY + mathSin(angle) * labelDistance, // a/a + positions[0] + radiusX + mathCos(angle) * connectorOffset, // second break, right outside pie + positions[1] + radiusY + mathSin(angle) * connectorOffset, // a/a + positions[0] + radiusX, // landing point for connector + positions[1] + radiusY, // a/a + labelDistance < 0 ? // alignment + 'center' : + point.half ? 'right' : 'left', // alignment + angle // center angle + ]; + + } + }, + + drawGraph: null, + + /** + * Draw the data points + */ + drawPoints: function () { + var series = this, + chart = series.chart, + renderer = chart.renderer, + groupTranslation, + //center, + graphic, + //group, + shadow = series.options.shadow, + shadowGroup, + shapeArgs; + + if (shadow && !series.shadowGroup) { + series.shadowGroup = renderer.g('shadow') + .add(series.group); + } + + // draw the slices + each(series.points, function (point) { + graphic = point.graphic; + shapeArgs = point.shapeArgs; + shadowGroup = point.shadowGroup; + + // put the shadow behind all points + if (shadow && !shadowGroup) { + shadowGroup = point.shadowGroup = renderer.g('shadow') + .add(series.shadowGroup); + } + + // if the point is sliced, use special translation, else use plot area traslation + groupTranslation = point.sliced ? point.slicedTranslation : { + translateX: 0, + translateY: 0 + }; + + //group.translate(groupTranslation[0], groupTranslation[1]); + if (shadowGroup) { + shadowGroup.attr(groupTranslation); + } + + // draw the slice + if (graphic) { + graphic.animate(extend(shapeArgs, groupTranslation)); + } else { + point.graphic = graphic = renderer[point.shapeType](shapeArgs) + .setRadialReference(series.center) + .attr( + point.pointAttr[point.selected ? SELECT_STATE : NORMAL_STATE] + ) + .attr({ + 'stroke-linejoin': 'round' + //zIndex: 1 // #2722 (reversed) + }) + .attr(groupTranslation) + .add(series.group) + .shadow(shadow, shadowGroup); + } + + // detect point specific visibility (#2430) + if (point.visible !== undefined) { + point.setVisible(point.visible); + } + + }); + + }, + + /** + * Utility for sorting data labels + */ + sortByAngle: function (points, sign) { + points.sort(function (a, b) { + return a.angle !== undefined && (b.angle - a.angle) * sign; + }); + }, + + /** + * Use a simple symbol from LegendSymbolMixin + */ + drawLegendSymbol: LegendSymbolMixin.drawRectangle, + + /** + * Use the getCenter method from drawLegendSymbol + */ + getCenter: CenteredSeriesMixin.getCenter, + + /** + * Pies don't have point marker symbols + */ + getSymbol: noop + +}; +PieSeries = extendClass(Series, PieSeries); +seriesTypes.pie = PieSeries; + +/** + * Draw the data labels + */ +Series.prototype.drawDataLabels = function () { + + var series = this, + seriesOptions = series.options, + cursor = seriesOptions.cursor, + options = seriesOptions.dataLabels, + points = series.points, + pointOptions, + generalOptions, + str, + dataLabelsGroup; + + if (options.enabled || series._hasPointLabels) { + + // Process default alignment of data labels for columns + if (series.dlProcessOptions) { + series.dlProcessOptions(options); + } + + // Create a separate group for the data labels to avoid rotation + dataLabelsGroup = series.plotGroup( + 'dataLabelsGroup', + 'data-labels', + options.defer ? HIDDEN : VISIBLE, + options.zIndex || 6 + ); + + if (!series.hasRendered && pick(options.defer, true)) { + dataLabelsGroup.attr({ opacity: 0 }); + addEvent(series, 'afterAnimate', function () { + if (series.visible) { // #3023, #3024 + dataLabelsGroup.show(); + } + dataLabelsGroup[seriesOptions.animation ? 'animate' : 'attr']({ opacity: 1 }, { duration: 200 }); + }); + } + + // Make the labels for each point + generalOptions = options; + each(points, function (point) { + + var enabled, + dataLabel = point.dataLabel, + labelConfig, + attr, + name, + rotation, + connector = point.connector, + isNew = true; + + // Determine if each data label is enabled + pointOptions = point.options && point.options.dataLabels; + enabled = pick(pointOptions && pointOptions.enabled, generalOptions.enabled); // #2282 + + + // If the point is outside the plot area, destroy it. #678, #820 + if (dataLabel && !enabled) { + point.dataLabel = dataLabel.destroy(); + + // Individual labels are disabled if the are explicitly disabled + // in the point options, or if they fall outside the plot area. + } else if (enabled) { + + // Create individual options structure that can be extended without + // affecting others + options = merge(generalOptions, pointOptions); + + rotation = options.rotation; + + // Get the string + labelConfig = point.getLabelConfig(); + str = options.format ? + format(options.format, labelConfig) : + options.formatter.call(labelConfig, options); + + // Determine the color + options.style.color = pick(options.color, options.style.color, series.color, 'black'); + + + // update existing label + if (dataLabel) { + + if (defined(str)) { + dataLabel + .attr({ + text: str + }); + isNew = false; + + } else { // #1437 - the label is shown conditionally + point.dataLabel = dataLabel = dataLabel.destroy(); + if (connector) { + point.connector = connector.destroy(); + } + } + + // create new label + } else if (defined(str)) { + attr = { + //align: align, + fill: options.backgroundColor, + stroke: options.borderColor, + 'stroke-width': options.borderWidth, + r: options.borderRadius || 0, + rotation: rotation, + padding: options.padding, + zIndex: 1 + }; + // Remove unused attributes (#947) + for (name in attr) { + if (attr[name] === UNDEFINED) { + delete attr[name]; + } + } + + dataLabel = point.dataLabel = series.chart.renderer[rotation ? 'text' : 'label']( // labels don't support rotation + str, + 0, + -999, + null, + null, + null, + options.useHTML + ) + .attr(attr) + .css(extend(options.style, cursor && { cursor: cursor })) + .add(dataLabelsGroup) + .shadow(options.shadow); + + } + + if (dataLabel) { + // Now the data label is created and placed at 0,0, so we need to align it + series.alignDataLabel(point, dataLabel, options, null, isNew); + } + } + }); + } +}; + +/** + * Align each individual data label + */ +Series.prototype.alignDataLabel = function (point, dataLabel, options, alignTo, isNew) { + var chart = this.chart, + inverted = chart.inverted, + plotX = pick(point.plotX, -999), + plotY = pick(point.plotY, -999), + bBox = dataLabel.getBBox(), + // Math.round for rounding errors (#2683), alignTo to allow column labels (#2700) + visible = this.visible && (point.series.forceDL || chart.isInsidePlot(plotX, mathRound(plotY), inverted) || + (alignTo && chart.isInsidePlot(plotX, inverted ? alignTo.x + 1 : alignTo.y + alignTo.height - 1, inverted))), + alignAttr; // the final position; + + if (visible) { + + // The alignment box is a singular point + alignTo = extend({ + x: inverted ? chart.plotWidth - plotY : plotX, + y: mathRound(inverted ? chart.plotHeight - plotX : plotY), + width: 0, + height: 0 + }, alignTo); + + // Add the text size for alignment calculation + extend(options, { + width: bBox.width, + height: bBox.height + }); + + // Allow a hook for changing alignment in the last moment, then do the alignment + if (options.rotation) { // Fancy box alignment isn't supported for rotated text + dataLabel[isNew ? 'attr' : 'animate']({ + x: alignTo.x + options.x + alignTo.width / 2, + y: alignTo.y + options.y + alignTo.height / 2 + }) + .attr({ // #3003 + align: options.align + }); + } else { + dataLabel.align(options, null, alignTo); + alignAttr = dataLabel.alignAttr; + + // Handle justify or crop + if (pick(options.overflow, 'justify') === 'justify') { + this.justifyDataLabel(dataLabel, options, alignAttr, bBox, alignTo, isNew); + + } else if (pick(options.crop, true)) { + // Now check that the data label is within the plot area + visible = chart.isInsidePlot(alignAttr.x, alignAttr.y) && chart.isInsidePlot(alignAttr.x + bBox.width, alignAttr.y + bBox.height); + + } + } + } + + // Show or hide based on the final aligned position + if (!visible) { + dataLabel.attr({ y: -999 }); + dataLabel.placed = false; // don't animate back in + } + +}; + +/** + * If data labels fall partly outside the plot area, align them back in, in a way that + * doesn't hide the point. + */ +Series.prototype.justifyDataLabel = function (dataLabel, options, alignAttr, bBox, alignTo, isNew) { + var chart = this.chart, + align = options.align, + verticalAlign = options.verticalAlign, + off, + justified; + + // Off left + off = alignAttr.x; + if (off < 0) { + if (align === 'right') { + options.align = 'left'; + } else { + options.x = -off; + } + justified = true; + } + + // Off right + off = alignAttr.x + bBox.width; + if (off > chart.plotWidth) { + if (align === 'left') { + options.align = 'right'; + } else { + options.x = chart.plotWidth - off; + } + justified = true; + } + + // Off top + off = alignAttr.y; + if (off < 0) { + if (verticalAlign === 'bottom') { + options.verticalAlign = 'top'; + } else { + options.y = -off; + } + justified = true; + } + + // Off bottom + off = alignAttr.y + bBox.height; + if (off > chart.plotHeight) { + if (verticalAlign === 'top') { + options.verticalAlign = 'bottom'; + } else { + options.y = chart.plotHeight - off; + } + justified = true; + } + + if (justified) { + dataLabel.placed = !isNew; + dataLabel.align(options, null, alignTo); + } +}; + +/** + * Override the base drawDataLabels method by pie specific functionality + */ +if (seriesTypes.pie) { + seriesTypes.pie.prototype.drawDataLabels = function () { + var series = this, + data = series.data, + point, + chart = series.chart, + options = series.options.dataLabels, + connectorPadding = pick(options.connectorPadding, 10), + connectorWidth = pick(options.connectorWidth, 1), + plotWidth = chart.plotWidth, + plotHeight = chart.plotHeight, + connector, + connectorPath, + softConnector = pick(options.softConnector, true), + distanceOption = options.distance, + seriesCenter = series.center, + radius = seriesCenter[2] / 2, + centerY = seriesCenter[1], + outside = distanceOption > 0, + dataLabel, + dataLabelWidth, + labelPos, + labelHeight, + halves = [// divide the points into right and left halves for anti collision + [], // right + [] // left + ], + x, + y, + visibility, + rankArr, + i, + j, + overflow = [0, 0, 0, 0], // top, right, bottom, left + sort = function (a, b) { + return b.y - a.y; + }; + + // get out if not enabled + if (!series.visible || (!options.enabled && !series._hasPointLabels)) { + return; + } + + // run parent method + Series.prototype.drawDataLabels.apply(series); + + // arrange points for detection collision + each(data, function (point) { + if (point.dataLabel && point.visible) { // #407, #2510 + halves[point.half].push(point); + } + }); + + /* Loop over the points in each half, starting from the top and bottom + * of the pie to detect overlapping labels. + */ + i = 2; + while (i--) { + + var slots = [], + slotsLength, + usedSlots = [], + points = halves[i], + pos, + bottom, + length = points.length, + slotIndex; + + if (!length) { + continue; + } + + // Sort by angle + series.sortByAngle(points, i - 0.5); + + // Assume equal label heights on either hemisphere (#2630) + j = labelHeight = 0; + while (!labelHeight && points[j]) { // #1569 + labelHeight = points[j] && points[j].dataLabel && (points[j].dataLabel.getBBox().height || 21); // 21 is for #968 + j++; + } + + // Only do anti-collision when we are outside the pie and have connectors (#856) + if (distanceOption > 0) { + + // Build the slots + bottom = mathMin(centerY + radius + distanceOption, chart.plotHeight); + for (pos = mathMax(0, centerY - radius - distanceOption); pos <= bottom; pos += labelHeight) { + slots.push(pos); + } + slotsLength = slots.length; + + + /* Visualize the slots + if (!series.slotElements) { + series.slotElements = []; + } + if (i === 1) { + series.slotElements.forEach(function (elem) { + elem.destroy(); + }); + series.slotElements.length = 0; + } + + slots.forEach(function (pos, no) { + var slotX = series.getX(pos, i) + chart.plotLeft - (i ? 100 : 0), + slotY = pos + chart.plotTop; + + if (!isNaN(slotX)) { + series.slotElements.push(chart.renderer.rect(slotX, slotY - 7, 100, labelHeight, 1) + .attr({ + 'stroke-width': 1, + stroke: 'silver', + fill: 'rgba(0,0,255,0.1)' + }) + .add()); + series.slotElements.push(chart.renderer.text('Slot '+ no, slotX, slotY + 4) + .attr({ + fill: 'silver' + }).add()); + } + }); + // */ + + // if there are more values than available slots, remove lowest values + if (length > slotsLength) { + // create an array for sorting and ranking the points within each quarter + rankArr = [].concat(points); + rankArr.sort(sort); + j = length; + while (j--) { + rankArr[j].rank = j; + } + j = length; + while (j--) { + if (points[j].rank >= slotsLength) { + points.splice(j, 1); + } + } + length = points.length; + } + + // The label goes to the nearest open slot, but not closer to the edge than + // the label's index. + for (j = 0; j < length; j++) { + + point = points[j]; + labelPos = point.labelPos; + + var closest = 9999, + distance, + slotI; + + // find the closest slot index + for (slotI = 0; slotI < slotsLength; slotI++) { + distance = mathAbs(slots[slotI] - labelPos[1]); + if (distance < closest) { + closest = distance; + slotIndex = slotI; + } + } + + // if that slot index is closer to the edges of the slots, move it + // to the closest appropriate slot + if (slotIndex < j && slots[j] !== null) { // cluster at the top + slotIndex = j; + } else if (slotsLength < length - j + slotIndex && slots[j] !== null) { // cluster at the bottom + slotIndex = slotsLength - length + j; + while (slots[slotIndex] === null) { // make sure it is not taken + slotIndex++; + } + } else { + // Slot is taken, find next free slot below. In the next run, the next slice will find the + // slot above these, because it is the closest one + while (slots[slotIndex] === null) { // make sure it is not taken + slotIndex++; + } + } + + usedSlots.push({ i: slotIndex, y: slots[slotIndex] }); + slots[slotIndex] = null; // mark as taken + } + // sort them in order to fill in from the top + usedSlots.sort(sort); + } + + // now the used slots are sorted, fill them up sequentially + for (j = 0; j < length; j++) { + + var slot, naturalY; + + point = points[j]; + labelPos = point.labelPos; + dataLabel = point.dataLabel; + visibility = point.visible === false ? HIDDEN : VISIBLE; + naturalY = labelPos[1]; + + if (distanceOption > 0) { + slot = usedSlots.pop(); + slotIndex = slot.i; + + // if the slot next to currrent slot is free, the y value is allowed + // to fall back to the natural position + y = slot.y; + if ((naturalY > y && slots[slotIndex + 1] !== null) || + (naturalY < y && slots[slotIndex - 1] !== null)) { + y = mathMin(mathMax(0, naturalY), chart.plotHeight); + } + + } else { + y = naturalY; + } + + // get the x - use the natural x position for first and last slot, to prevent the top + // and botton slice connectors from touching each other on either side + x = options.justify ? + seriesCenter[0] + (i ? -1 : 1) * (radius + distanceOption) : + series.getX(y === centerY - radius - distanceOption || y === centerY + radius + distanceOption ? naturalY : y, i); + + + // Record the placement and visibility + dataLabel._attr = { + visibility: visibility, + align: labelPos[6] + }; + dataLabel._pos = { + x: x + options.x + + ({ left: connectorPadding, right: -connectorPadding }[labelPos[6]] || 0), + y: y + options.y - 10 // 10 is for the baseline (label vs text) + }; + dataLabel.connX = x; + dataLabel.connY = y; + + + // Detect overflowing data labels + if (this.options.size === null) { + dataLabelWidth = dataLabel.width; + // Overflow left + if (x - dataLabelWidth < connectorPadding) { + overflow[3] = mathMax(mathRound(dataLabelWidth - x + connectorPadding), overflow[3]); + + // Overflow right + } else if (x + dataLabelWidth > plotWidth - connectorPadding) { + overflow[1] = mathMax(mathRound(x + dataLabelWidth - plotWidth + connectorPadding), overflow[1]); + } + + // Overflow top + if (y - labelHeight / 2 < 0) { + overflow[0] = mathMax(mathRound(-y + labelHeight / 2), overflow[0]); + + // Overflow left + } else if (y + labelHeight / 2 > plotHeight) { + overflow[2] = mathMax(mathRound(y + labelHeight / 2 - plotHeight), overflow[2]); + } + } + } // for each point + } // for each half + + // Do not apply the final placement and draw the connectors until we have verified + // that labels are not spilling over. + if (arrayMax(overflow) === 0 || this.verifyDataLabelOverflow(overflow)) { + + // Place the labels in the final position + this.placeDataLabels(); + + // Draw the connectors + if (outside && connectorWidth) { + each(this.points, function (point) { + connector = point.connector; + labelPos = point.labelPos; + dataLabel = point.dataLabel; + + if (dataLabel && dataLabel._pos) { + visibility = dataLabel._attr.visibility; + x = dataLabel.connX; + y = dataLabel.connY; + connectorPath = softConnector ? [ + M, + x + (labelPos[6] === 'left' ? 5 : -5), y, // end of the string at the label + 'C', + x, y, // first break, next to the label + 2 * labelPos[2] - labelPos[4], 2 * labelPos[3] - labelPos[5], + labelPos[2], labelPos[3], // second break + L, + labelPos[4], labelPos[5] // base + ] : [ + M, + x + (labelPos[6] === 'left' ? 5 : -5), y, // end of the string at the label + L, + labelPos[2], labelPos[3], // second break + L, + labelPos[4], labelPos[5] // base + ]; + + if (connector) { + connector.animate({ d: connectorPath }); + connector.attr('visibility', visibility); + + } else { + point.connector = connector = series.chart.renderer.path(connectorPath).attr({ + 'stroke-width': connectorWidth, + stroke: options.connectorColor || point.color || '#606060', + visibility: visibility + //zIndex: 0 // #2722 (reversed) + }) + .add(series.dataLabelsGroup); + } + } else if (connector) { + point.connector = connector.destroy(); + } + }); + } + } + }; + /** + * Perform the final placement of the data labels after we have verified that they + * fall within the plot area. + */ + seriesTypes.pie.prototype.placeDataLabels = function () { + each(this.points, function (point) { + var dataLabel = point.dataLabel, + _pos; + + if (dataLabel) { + _pos = dataLabel._pos; + if (_pos) { + dataLabel.attr(dataLabel._attr); + dataLabel[dataLabel.moved ? 'animate' : 'attr'](_pos); + dataLabel.moved = true; + } else if (dataLabel) { + dataLabel.attr({ y: -999 }); + } + } + }); + }; + + seriesTypes.pie.prototype.alignDataLabel = noop; + + /** + * Verify whether the data labels are allowed to draw, or we should run more translation and data + * label positioning to keep them inside the plot area. Returns true when data labels are ready + * to draw. + */ + seriesTypes.pie.prototype.verifyDataLabelOverflow = function (overflow) { + + var center = this.center, + options = this.options, + centerOption = options.center, + minSize = options.minSize || 80, + newSize = minSize, + ret; + + // Handle horizontal size and center + if (centerOption[0] !== null) { // Fixed center + newSize = mathMax(center[2] - mathMax(overflow[1], overflow[3]), minSize); + + } else { // Auto center + newSize = mathMax( + center[2] - overflow[1] - overflow[3], // horizontal overflow + minSize + ); + center[0] += (overflow[3] - overflow[1]) / 2; // horizontal center + } + + // Handle vertical size and center + if (centerOption[1] !== null) { // Fixed center + newSize = mathMax(mathMin(newSize, center[2] - mathMax(overflow[0], overflow[2])), minSize); + + } else { // Auto center + newSize = mathMax( + mathMin( + newSize, + center[2] - overflow[0] - overflow[2] // vertical overflow + ), + minSize + ); + center[1] += (overflow[0] - overflow[2]) / 2; // vertical center + } + + // If the size must be decreased, we need to run translate and drawDataLabels again + if (newSize < center[2]) { + center[2] = newSize; + this.translate(center); + each(this.points, function (point) { + if (point.dataLabel) { + point.dataLabel._pos = null; // reset + } + }); + + if (this.drawDataLabels) { + this.drawDataLabels(); + } + // Else, return true to indicate that the pie and its labels is within the plot area + } else { + ret = true; + } + return ret; + }; +} + +if (seriesTypes.column) { + + /** + * Override the basic data label alignment by adjusting for the position of the column + */ + seriesTypes.column.prototype.alignDataLabel = function (point, dataLabel, options, alignTo, isNew) { + var chart = this.chart, + inverted = chart.inverted, + dlBox = point.dlBox || point.shapeArgs, // data label box for alignment + below = point.below || (point.plotY > pick(this.translatedThreshold, chart.plotSizeY)), + inside = pick(options.inside, !!this.options.stacking); // draw it inside the box? + + // Align to the column itself, or the top of it + if (dlBox) { // Area range uses this method but not alignTo + alignTo = merge(dlBox); + + if (inverted) { + alignTo = { + x: chart.plotWidth - alignTo.y - alignTo.height, + y: chart.plotHeight - alignTo.x - alignTo.width, + width: alignTo.height, + height: alignTo.width + }; + } + + // Compute the alignment box + if (!inside) { + if (inverted) { + alignTo.x += below ? 0 : alignTo.width; + alignTo.width = 0; + } else { + alignTo.y += below ? alignTo.height : 0; + alignTo.height = 0; + } + } + } + + + // When alignment is undefined (typically columns and bars), display the individual + // point below or above the point depending on the threshold + options.align = pick( + options.align, + !inverted || inside ? 'center' : below ? 'right' : 'left' + ); + options.verticalAlign = pick( + options.verticalAlign, + inverted || inside ? 'middle' : below ? 'top' : 'bottom' + ); + + // Call the parent method + Series.prototype.alignDataLabel.call(this, point, dataLabel, options, alignTo, isNew); + }; +} + + + +/** + * TrackerMixin for points and graphs + */ + +var TrackerMixin = Highcharts.TrackerMixin = { + + drawTrackerPoint: function () { + var series = this, + chart = series.chart, + pointer = chart.pointer, + cursor = series.options.cursor, + css = cursor && { cursor: cursor }, + onMouseOver = function (e) { + var target = e.target, + point; + + if (chart.hoverSeries !== series) { + series.onMouseOver(); + } + + while (target && !point) { + point = target.point; + target = target.parentNode; + } + + if (point !== UNDEFINED && point !== chart.hoverPoint) { // undefined on graph in scatterchart + point.onMouseOver(e); + } + }; + + // Add reference to the point + each(series.points, function (point) { + if (point.graphic) { + point.graphic.element.point = point; + } + if (point.dataLabel) { + point.dataLabel.element.point = point; + } + }); + + // Add the event listeners, we need to do this only once + if (!series._hasTracking) { + each(series.trackerGroups, function (key) { + if (series[key]) { // we don't always have dataLabelsGroup + series[key] + .addClass(PREFIX + 'tracker') + .on('mouseover', onMouseOver) + .on('mouseout', function (e) { pointer.onTrackerMouseOut(e); }) + .css(css); + if (hasTouch) { + series[key].on('touchstart', onMouseOver); + } + } + }); + series._hasTracking = true; + } + }, + + /** + * Draw the tracker object that sits above all data labels and markers to + * track mouse events on the graph or points. For the line type charts + * the tracker uses the same graphPath, but with a greater stroke width + * for better control. + */ + drawTrackerGraph: function () { + var series = this, + options = series.options, + trackByArea = options.trackByArea, + trackerPath = [].concat(trackByArea ? series.areaPath : series.graphPath), + trackerPathLength = trackerPath.length, + chart = series.chart, + pointer = chart.pointer, + renderer = chart.renderer, + snap = chart.options.tooltip.snap, + tracker = series.tracker, + cursor = options.cursor, + css = cursor && { cursor: cursor }, + singlePoints = series.singlePoints, + singlePoint, + i, + onMouseOver = function () { + if (chart.hoverSeries !== series) { + series.onMouseOver(); + } + }, + /* + * Empirical lowest possible opacities for TRACKER_FILL for an element to stay invisible but clickable + * IE6: 0.002 + * IE7: 0.002 + * IE8: 0.002 + * IE9: 0.00000000001 (unlimited) + * IE10: 0.0001 (exporting only) + * FF: 0.00000000001 (unlimited) + * Chrome: 0.000001 + * Safari: 0.000001 + * Opera: 0.00000000001 (unlimited) + */ + TRACKER_FILL = 'rgba(192,192,192,' + (hasSVG ? 0.0001 : 0.002) + ')'; + + // Extend end points. A better way would be to use round linecaps, + // but those are not clickable in VML. + if (trackerPathLength && !trackByArea) { + i = trackerPathLength + 1; + while (i--) { + if (trackerPath[i] === M) { // extend left side + trackerPath.splice(i + 1, 0, trackerPath[i + 1] - snap, trackerPath[i + 2], L); + } + if ((i && trackerPath[i] === M) || i === trackerPathLength) { // extend right side + trackerPath.splice(i, 0, L, trackerPath[i - 2] + snap, trackerPath[i - 1]); + } + } + } + + // handle single points + for (i = 0; i < singlePoints.length; i++) { + singlePoint = singlePoints[i]; + trackerPath.push(M, singlePoint.plotX - snap, singlePoint.plotY, + L, singlePoint.plotX + snap, singlePoint.plotY); + } + + // draw the tracker + if (tracker) { + tracker.attr({ d: trackerPath }); + } else { // create + + series.tracker = renderer.path(trackerPath) + .attr({ + 'stroke-linejoin': 'round', // #1225 + visibility: series.visible ? VISIBLE : HIDDEN, + stroke: TRACKER_FILL, + fill: trackByArea ? TRACKER_FILL : NONE, + 'stroke-width' : options.lineWidth + (trackByArea ? 0 : 2 * snap), + zIndex: 2 + }) + .add(series.group); + + // The tracker is added to the series group, which is clipped, but is covered + // by the marker group. So the marker group also needs to capture events. + each([series.tracker, series.markerGroup], function (tracker) { + tracker.addClass(PREFIX + 'tracker') + .on('mouseover', onMouseOver) + .on('mouseout', function (e) { pointer.onTrackerMouseOut(e); }) + .css(css); + + if (hasTouch) { + tracker.on('touchstart', onMouseOver); + } + }); + } + } +}; +/* End TrackerMixin */ + + +/** + * Add tracking event listener to the series group, so the point graphics + * themselves act as trackers + */ + +if (seriesTypes.column) { + ColumnSeries.prototype.drawTracker = TrackerMixin.drawTrackerPoint; +} + +if (seriesTypes.pie) { + seriesTypes.pie.prototype.drawTracker = TrackerMixin.drawTrackerPoint; +} + +if (seriesTypes.scatter) { + ScatterSeries.prototype.drawTracker = TrackerMixin.drawTrackerPoint; +} + +/* + * Extend Legend for item events + */ +extend(Legend.prototype, { + + setItemEvents: function (item, legendItem, useHTML, itemStyle, itemHiddenStyle) { + var legend = this; + // Set the events on the item group, or in case of useHTML, the item itself (#1249) + (useHTML ? legendItem : item.legendGroup).on('mouseover', function () { + item.setState(HOVER_STATE); + legendItem.css(legend.options.itemHoverStyle); + }) + .on('mouseout', function () { + legendItem.css(item.visible ? itemStyle : itemHiddenStyle); + item.setState(); + }) + .on('click', function (event) { + var strLegendItemClick = 'legendItemClick', + fnLegendItemClick = function () { + item.setVisible(); + }; + + // Pass over the click/touch event. #4. + event = { + browserEvent: event + }; + + // click the name or symbol + if (item.firePointEvent) { // point + item.firePointEvent(strLegendItemClick, event, fnLegendItemClick); + } else { + fireEvent(item, strLegendItemClick, event, fnLegendItemClick); + } + }); + }, + + createCheckboxForItem: function (item) { + var legend = this; + + item.checkbox = createElement('input', { + type: 'checkbox', + checked: item.selected, + defaultChecked: item.selected // required by IE7 + }, legend.options.itemCheckboxStyle, legend.chart.container); + + addEvent(item.checkbox, 'click', function (event) { + var target = event.target; + fireEvent(item, 'checkboxClick', { + checked: target.checked + }, + function () { + item.select(); + } + ); + }); + } +}); + +/* + * Add pointer cursor to legend itemstyle in defaultOptions + */ +defaultOptions.legend.itemStyle.cursor = 'pointer'; + + +/* + * Extend the Chart object with interaction + */ + +extend(Chart.prototype, { + /** + * Display the zoom button + */ + showResetZoom: function () { + var chart = this, + lang = defaultOptions.lang, + btnOptions = chart.options.chart.resetZoomButton, + theme = btnOptions.theme, + states = theme.states, + alignTo = btnOptions.relativeTo === 'chart' ? null : 'plotBox'; + + this.resetZoomButton = chart.renderer.button(lang.resetZoom, null, null, function () { chart.zoomOut(); }, theme, states && states.hover) + .attr({ + align: btnOptions.position.align, + title: lang.resetZoomTitle + }) + .add() + .align(btnOptions.position, false, alignTo); + + }, + + /** + * Zoom out to 1:1 + */ + zoomOut: function () { + var chart = this; + fireEvent(chart, 'selection', { resetSelection: true }, function () { + chart.zoom(); + }); + }, + + /** + * Zoom into a given portion of the chart given by axis coordinates + * @param {Object} event + */ + zoom: function (event) { + var chart = this, + hasZoomed, + pointer = chart.pointer, + displayButton = false, + resetZoomButton; + + // If zoom is called with no arguments, reset the axes + if (!event || event.resetSelection) { + each(chart.axes, function (axis) { + hasZoomed = axis.zoom(); + }); + } else { // else, zoom in on all axes + each(event.xAxis.concat(event.yAxis), function (axisData) { + var axis = axisData.axis, + isXAxis = axis.isXAxis; + + // don't zoom more than minRange + if (pointer[isXAxis ? 'zoomX' : 'zoomY'] || pointer[isXAxis ? 'pinchX' : 'pinchY']) { + hasZoomed = axis.zoom(axisData.min, axisData.max); + if (axis.displayBtn) { + displayButton = true; + } + } + }); + } + + // Show or hide the Reset zoom button + resetZoomButton = chart.resetZoomButton; + if (displayButton && !resetZoomButton) { + chart.showResetZoom(); + } else if (!displayButton && isObject(resetZoomButton)) { + chart.resetZoomButton = resetZoomButton.destroy(); + } + + + // Redraw + if (hasZoomed) { + chart.redraw( + pick(chart.options.chart.animation, event && event.animation, chart.pointCount < 100) // animation + ); + } + }, + + /** + * Pan the chart by dragging the mouse across the pane. This function is called + * on mouse move, and the distance to pan is computed from chartX compared to + * the first chartX position in the dragging operation. + */ + pan: function (e, panning) { + + var chart = this, + hoverPoints = chart.hoverPoints, + doRedraw; + + // remove active points for shared tooltip + if (hoverPoints) { + each(hoverPoints, function (point) { + point.setState(); + }); + } + + each(panning === 'xy' ? [1, 0] : [1], function (isX) { // xy is used in maps + var mousePos = e[isX ? 'chartX' : 'chartY'], + axis = chart[isX ? 'xAxis' : 'yAxis'][0], + startPos = chart[isX ? 'mouseDownX' : 'mouseDownY'], + halfPointRange = (axis.pointRange || 0) / 2, + extremes = axis.getExtremes(), + newMin = axis.toValue(startPos - mousePos, true) + halfPointRange, + newMax = axis.toValue(startPos + chart[isX ? 'plotWidth' : 'plotHeight'] - mousePos, true) - halfPointRange; + + if (axis.series.length && newMin > mathMin(extremes.dataMin, extremes.min) && newMax < mathMax(extremes.dataMax, extremes.max)) { + axis.setExtremes(newMin, newMax, false, false, { trigger: 'pan' }); + doRedraw = true; + } + + chart[isX ? 'mouseDownX' : 'mouseDownY'] = mousePos; // set new reference for next run + }); + + if (doRedraw) { + chart.redraw(false); + } + css(chart.container, { cursor: 'move' }); + } +}); + +/* + * Extend the Point object with interaction + */ +extend(Point.prototype, { + /** + * Toggle the selection status of a point + * @param {Boolean} selected Whether to select or unselect the point. + * @param {Boolean} accumulate Whether to add to the previous selection. By default, + * this happens if the control key (Cmd on Mac) was pressed during clicking. + */ + select: function (selected, accumulate) { + var point = this, + series = point.series, + chart = series.chart; + + selected = pick(selected, !point.selected); + + // fire the event with the defalut handler + point.firePointEvent(selected ? 'select' : 'unselect', { accumulate: accumulate }, function () { + point.selected = point.options.selected = selected; + series.options.data[inArray(point, series.data)] = point.options; + + point.setState(selected && SELECT_STATE); + + // unselect all other points unless Ctrl or Cmd + click + if (!accumulate) { + each(chart.getSelectedPoints(), function (loopPoint) { + if (loopPoint.selected && loopPoint !== point) { + loopPoint.selected = loopPoint.options.selected = false; + series.options.data[inArray(loopPoint, series.data)] = loopPoint.options; + loopPoint.setState(NORMAL_STATE); + loopPoint.firePointEvent('unselect'); + } + }); + } + }); + }, + + /** + * Runs on mouse over the point + */ + onMouseOver: function (e) { + var point = this, + series = point.series, + chart = series.chart, + tooltip = chart.tooltip, + hoverPoint = chart.hoverPoint; + + // set normal state to previous series + if (hoverPoint && hoverPoint !== point) { + hoverPoint.onMouseOut(); + } + + // trigger the event + point.firePointEvent('mouseOver'); + + // update the tooltip + if (tooltip && (!tooltip.shared || series.noSharedTooltip)) { + tooltip.refresh(point, e); + } + + // hover this + point.setState(HOVER_STATE); + chart.hoverPoint = point; + }, + + /** + * Runs on mouse out from the point + */ + onMouseOut: function () { + var chart = this.series.chart, + hoverPoints = chart.hoverPoints; + + this.firePointEvent('mouseOut'); + + if (!hoverPoints || inArray(this, hoverPoints) === -1) { // #887, #2240 + this.setState(); + chart.hoverPoint = null; + } + }, + + /** + * Import events from the series' and point's options. Only do it on + * demand, to save processing time on hovering. + */ + importEvents: function () { + if (!this.hasImportedEvents) { + var point = this, + options = merge(point.series.options.point, point.options), + events = options.events, + eventType; + + point.events = events; + + for (eventType in events) { + addEvent(point, eventType, events[eventType]); + } + this.hasImportedEvents = true; + + } + }, + + /** + * Set the point's state + * @param {String} state + */ + setState: function (state, move) { + var point = this, + plotX = point.plotX, + plotY = point.plotY, + series = point.series, + stateOptions = series.options.states, + markerOptions = defaultPlotOptions[series.type].marker && series.options.marker, + normalDisabled = markerOptions && !markerOptions.enabled, + markerStateOptions = markerOptions && markerOptions.states[state], + stateDisabled = markerStateOptions && markerStateOptions.enabled === false, + stateMarkerGraphic = series.stateMarkerGraphic, + pointMarker = point.marker || {}, + chart = series.chart, + radius, + halo = series.halo, + haloOptions, + newSymbol, + pointAttr; + + state = state || NORMAL_STATE; // empty string + pointAttr = point.pointAttr[state] || series.pointAttr[state]; + + if ( + // already has this state + (state === point.state && !move) || + // selected points don't respond to hover + (point.selected && state !== SELECT_STATE) || + // series' state options is disabled + (stateOptions[state] && stateOptions[state].enabled === false) || + // general point marker's state options is disabled + (state && (stateDisabled || (normalDisabled && markerStateOptions.enabled === false))) || + // individual point marker's state options is disabled + (state && pointMarker.states && pointMarker.states[state] && pointMarker.states[state].enabled === false) // #1610 + + ) { + return; + } + + // apply hover styles to the existing point + if (point.graphic) { + radius = markerOptions && point.graphic.symbolName && pointAttr.r; + point.graphic.attr(merge( + pointAttr, + radius ? { // new symbol attributes (#507, #612) + x: plotX - radius, + y: plotY - radius, + width: 2 * radius, + height: 2 * radius + } : {} + )); + + // Zooming in from a range with no markers to a range with markers + if (stateMarkerGraphic) { + stateMarkerGraphic.hide(); + } + } else { + // if a graphic is not applied to each point in the normal state, create a shared + // graphic for the hover state + if (state && markerStateOptions) { + radius = markerStateOptions.radius; + newSymbol = pointMarker.symbol || series.symbol; + + // If the point has another symbol than the previous one, throw away the + // state marker graphic and force a new one (#1459) + if (stateMarkerGraphic && stateMarkerGraphic.currentSymbol !== newSymbol) { + stateMarkerGraphic = stateMarkerGraphic.destroy(); + } + + // Add a new state marker graphic + if (!stateMarkerGraphic) { + if (newSymbol) { + series.stateMarkerGraphic = stateMarkerGraphic = chart.renderer.symbol( + newSymbol, + plotX - radius, + plotY - radius, + 2 * radius, + 2 * radius + ) + .attr(pointAttr) + .add(series.markerGroup); + stateMarkerGraphic.currentSymbol = newSymbol; + } + + // Move the existing graphic + } else { + stateMarkerGraphic[move ? 'animate' : 'attr']({ // #1054 + x: plotX - radius, + y: plotY - radius + }); + } + } + + if (stateMarkerGraphic) { + stateMarkerGraphic[state && chart.isInsidePlot(plotX, plotY, chart.inverted) ? 'show' : 'hide'](); // #2450 + } + } + + // Show me your halo + haloOptions = stateOptions[state] && stateOptions[state].halo; + if (haloOptions && haloOptions.size) { + if (!halo) { + series.halo = halo = chart.renderer.path() + .add(series.seriesGroup); + } + halo.attr(extend({ + fill: Color(point.color || series.color).setOpacity(haloOptions.opacity).get() + }, haloOptions.attributes))[move ? 'animate' : 'attr']({ + d: point.haloPath(haloOptions.size) + }); + } else if (halo) { + halo.attr({ d: [] }); + } + + point.state = state; + }, + + haloPath: function (size) { + var series = this.series, + chart = series.chart, + plotBox = series.getPlotBox(), + inverted = chart.inverted; + + return chart.renderer.symbols.circle( + plotBox.translateX + (inverted ? series.yAxis.len - this.plotY : this.plotX) - size, + plotBox.translateY + (inverted ? series.xAxis.len - this.plotX : this.plotY) - size, + size * 2, + size * 2 + ); + } +}); + +/* + * Extend the Series object with interaction + */ + +extend(Series.prototype, { + /** + * Series mouse over handler + */ + onMouseOver: function () { + var series = this, + chart = series.chart, + hoverSeries = chart.hoverSeries; + + // set normal state to previous series + if (hoverSeries && hoverSeries !== series) { + hoverSeries.onMouseOut(); + } + + // trigger the event, but to save processing time, + // only if defined + if (series.options.events.mouseOver) { + fireEvent(series, 'mouseOver'); + } + + // hover this + series.setState(HOVER_STATE); + chart.hoverSeries = series; + }, + + /** + * Series mouse out handler + */ + onMouseOut: function () { + // trigger the event only if listeners exist + var series = this, + options = series.options, + chart = series.chart, + tooltip = chart.tooltip, + hoverPoint = chart.hoverPoint; + + // trigger mouse out on the point, which must be in this series + if (hoverPoint) { + hoverPoint.onMouseOut(); + } + + // fire the mouse out event + if (series && options.events.mouseOut) { + fireEvent(series, 'mouseOut'); + } + + + // hide the tooltip + if (tooltip && !options.stickyTracking && (!tooltip.shared || series.noSharedTooltip)) { + tooltip.hide(); + } + + // set normal state + series.setState(); + chart.hoverSeries = null; + }, + + /** + * Set the state of the graph + */ + setState: function (state) { + var series = this, + options = series.options, + graph = series.graph, + graphNeg = series.graphNeg, + stateOptions = options.states, + lineWidth = options.lineWidth, + attribs; + + state = state || NORMAL_STATE; + + if (series.state !== state) { + series.state = state; + + if (stateOptions[state] && stateOptions[state].enabled === false) { + return; + } + + if (state) { + lineWidth = stateOptions[state].lineWidth || lineWidth + (stateOptions[state].lineWidthPlus || 0); + } + + if (graph && !graph.dashstyle) { // hover is turned off for dashed lines in VML + attribs = { + 'stroke-width': lineWidth + }; + // use attr because animate will cause any other animation on the graph to stop + graph.attr(attribs); + if (graphNeg) { + graphNeg.attr(attribs); + } + } + } + }, + + /** + * Set the visibility of the graph + * + * @param vis {Boolean} True to show the series, false to hide. If UNDEFINED, + * the visibility is toggled. + */ + setVisible: function (vis, redraw) { + var series = this, + chart = series.chart, + legendItem = series.legendItem, + showOrHide, + ignoreHiddenSeries = chart.options.chart.ignoreHiddenSeries, + oldVisibility = series.visible; + + // if called without an argument, toggle visibility + series.visible = vis = series.userOptions.visible = vis === UNDEFINED ? !oldVisibility : vis; + showOrHide = vis ? 'show' : 'hide'; + + // show or hide elements + each(['group', 'dataLabelsGroup', 'markerGroup', 'tracker'], function (key) { + if (series[key]) { + series[key][showOrHide](); + } + }); + + + // hide tooltip (#1361) + if (chart.hoverSeries === series) { + series.onMouseOut(); + } + + + if (legendItem) { + chart.legend.colorizeItem(series, vis); + } + + + // rescale or adapt to resized chart + series.isDirty = true; + // in a stack, all other series are affected + if (series.options.stacking) { + each(chart.series, function (otherSeries) { + if (otherSeries.options.stacking && otherSeries.visible) { + otherSeries.isDirty = true; + } + }); + } + + // show or hide linked series + each(series.linkedSeries, function (otherSeries) { + otherSeries.setVisible(vis, false); + }); + + if (ignoreHiddenSeries) { + chart.isDirtyBox = true; + } + if (redraw !== false) { + chart.redraw(); + } + + fireEvent(series, showOrHide); + }, + + /** + * Memorize tooltip texts and positions + */ + setTooltipPoints: function (renew) { + var series = this, + points = [], + pointsLength, + low, + high, + xAxis = series.xAxis, + xExtremes = xAxis && xAxis.getExtremes(), + axisLength = xAxis ? (xAxis.tooltipLen || xAxis.len) : series.chart.plotSizeX, // tooltipLen and tooltipPosName used in polar + point, + pointX, + nextPoint, + i, + tooltipPoints = []; // a lookup array for each pixel in the x dimension + + // don't waste resources if tracker is disabled + if (series.options.enableMouseTracking === false || series.singularTooltips) { + return; + } + + // renew + if (renew) { + series.tooltipPoints = null; + } + + // concat segments to overcome null values + each(series.segments || series.points, function (segment) { + points = points.concat(segment); + }); + + // Reverse the points in case the X axis is reversed + if (xAxis && xAxis.reversed) { + points = points.reverse(); + } + + // Polar needs additional shaping + if (series.orderTooltipPoints) { + series.orderTooltipPoints(points); + } + + // Assign each pixel position to the nearest point + pointsLength = points.length; + for (i = 0; i < pointsLength; i++) { + point = points[i]; + pointX = point.x; + if (pointX >= xExtremes.min && pointX <= xExtremes.max) { // #1149 + nextPoint = points[i + 1]; + + // Set this range's low to the last range's high plus one + low = high === UNDEFINED ? 0 : high + 1; + // Now find the new high + high = points[i + 1] ? + mathMin(mathMax(0, mathFloor( // #2070 + (point.clientX + (nextPoint ? (nextPoint.wrappedClientX || nextPoint.clientX) : axisLength)) / 2 + )), axisLength) : + axisLength; + + while (low >= 0 && low <= high) { + tooltipPoints[low++] = point; + } + } + } + series.tooltipPoints = tooltipPoints; + }, + + /** + * Show the graph + */ + show: function () { + this.setVisible(true); + }, + + /** + * Hide the graph + */ + hide: function () { + this.setVisible(false); + }, + + + /** + * Set the selected state of the graph + * + * @param selected {Boolean} True to select the series, false to unselect. If + * UNDEFINED, the selection state is toggled. + */ + select: function (selected) { + var series = this; + // if called without an argument, toggle + series.selected = selected = (selected === UNDEFINED) ? !series.selected : selected; + + if (series.checkbox) { + series.checkbox.checked = selected; + } + + fireEvent(series, selected ? 'select' : 'unselect'); + }, + + drawTracker: TrackerMixin.drawTrackerGraph +});/* **************************************************************************** + * Start ordinal axis logic * + *****************************************************************************/ + + +wrap(Series.prototype, 'init', function (proceed) { + var series = this, + xAxis; + + // call the original function + proceed.apply(this, Array.prototype.slice.call(arguments, 1)); + + xAxis = series.xAxis; + + // Destroy the extended ordinal index on updated data + if (xAxis && xAxis.options.ordinal) { + addEvent(series, 'updatedData', function () { + delete xAxis.ordinalIndex; + }); + } +}); + +/** + * In an ordinal axis, there might be areas with dense consentrations of points, then large + * gaps between some. Creating equally distributed ticks over this entire range + * may lead to a huge number of ticks that will later be removed. So instead, break the + * positions up in segments, find the tick positions for each segment then concatenize them. + * This method is used from both data grouping logic and X axis tick position logic. + */ +wrap(Axis.prototype, 'getTimeTicks', function (proceed, normalizedInterval, min, max, startOfWeek, positions, closestDistance, findHigherRanks) { + + var start = 0, + end = 0, + segmentPositions, + higherRanks = {}, + hasCrossedHigherRank, + info, + posLength, + outsideMax, + groupPositions = [], + lastGroupPosition = -Number.MAX_VALUE, + tickPixelIntervalOption = this.options.tickPixelInterval; + + // The positions are not always defined, for example for ordinal positions when data + // has regular interval (#1557, #2090) + if (!this.options.ordinal || !positions || positions.length < 3 || min === UNDEFINED) { + return proceed.call(this, normalizedInterval, min, max, startOfWeek); + } + + // Analyze the positions array to split it into segments on gaps larger than 5 times + // the closest distance. The closest distance is already found at this point, so + // we reuse that instead of computing it again. + posLength = positions.length; + for (; end < posLength; end++) { + + outsideMax = end && positions[end - 1] > max; + + if (positions[end] < min) { // Set the last position before min + start = end; + } + + if (end === posLength - 1 || positions[end + 1] - positions[end] > closestDistance * 5 || outsideMax) { + + // For each segment, calculate the tick positions from the getTimeTicks utility + // function. The interval will be the same regardless of how long the segment is. + if (positions[end] > lastGroupPosition) { // #1475 + + segmentPositions = proceed.call(this, normalizedInterval, positions[start], positions[end], startOfWeek); + + // Prevent duplicate groups, for example for multiple segments within one larger time frame (#1475) + while (segmentPositions.length && segmentPositions[0] <= lastGroupPosition) { + segmentPositions.shift(); + } + if (segmentPositions.length) { + lastGroupPosition = segmentPositions[segmentPositions.length - 1]; + } + + groupPositions = groupPositions.concat(segmentPositions); + } + // Set start of next segment + start = end + 1; + } + + if (outsideMax) { + break; + } + } + + // Get the grouping info from the last of the segments. The info is the same for + // all segments. + info = segmentPositions.info; + + // Optionally identify ticks with higher rank, for example when the ticks + // have crossed midnight. + if (findHigherRanks && info.unitRange <= timeUnits.hour) { + end = groupPositions.length - 1; + + // Compare points two by two + for (start = 1; start < end; start++) { + if (new Date(groupPositions[start] - timezoneOffset)[getDate]() !== new Date(groupPositions[start - 1] - timezoneOffset)[getDate]()) { + higherRanks[groupPositions[start]] = 'day'; + hasCrossedHigherRank = true; + } + } + + // If the complete array has crossed midnight, we want to mark the first + // positions also as higher rank + if (hasCrossedHigherRank) { + higherRanks[groupPositions[0]] = 'day'; + } + info.higherRanks = higherRanks; + } + + // Save the info + groupPositions.info = info; + + + + // Don't show ticks within a gap in the ordinal axis, where the space between + // two points is greater than a portion of the tick pixel interval + if (findHigherRanks && defined(tickPixelIntervalOption)) { // check for squashed ticks + + var length = groupPositions.length, + i = length, + itemToRemove, + translated, + translatedArr = [], + lastTranslated, + medianDistance, + distance, + distances = []; + + // Find median pixel distance in order to keep a reasonably even distance between + // ticks (#748) + while (i--) { + translated = this.translate(groupPositions[i]); + if (lastTranslated) { + distances[i] = lastTranslated - translated; + } + translatedArr[i] = lastTranslated = translated; + } + distances.sort(); + medianDistance = distances[mathFloor(distances.length / 2)]; + if (medianDistance < tickPixelIntervalOption * 0.6) { + medianDistance = null; + } + + // Now loop over again and remove ticks where needed + i = groupPositions[length - 1] > max ? length - 1 : length; // #817 + lastTranslated = undefined; + while (i--) { + translated = translatedArr[i]; + distance = lastTranslated - translated; + + // Remove ticks that are closer than 0.6 times the pixel interval from the one to the right, + // but not if it is close to the median distance (#748). + if (lastTranslated && distance < tickPixelIntervalOption * 0.8 && + (medianDistance === null || distance < medianDistance * 0.8)) { + + // Is this a higher ranked position with a normal position to the right? + if (higherRanks[groupPositions[i]] && !higherRanks[groupPositions[i + 1]]) { + + // Yes: remove the lower ranked neighbour to the right + itemToRemove = i + 1; + lastTranslated = translated; // #709 + + } else { + + // No: remove this one + itemToRemove = i; + } + + groupPositions.splice(itemToRemove, 1); + + } else { + lastTranslated = translated; + } + } + } + return groupPositions; +}); + +// Extend the Axis prototype +extend(Axis.prototype, { + + /** + * Calculate the ordinal positions before tick positions are calculated. + */ + beforeSetTickPositions: function () { + var axis = this, + len, + ordinalPositions = [], + useOrdinal = false, + dist, + extremes = axis.getExtremes(), + min = extremes.min, + max = extremes.max, + minIndex, + maxIndex, + slope, + i; + + // apply the ordinal logic + if (axis.options.ordinal) { + + each(axis.series, function (series, i) { + + if (series.visible !== false && series.takeOrdinalPosition !== false) { + + // concatenate the processed X data into the existing positions, or the empty array + ordinalPositions = ordinalPositions.concat(series.processedXData); + len = ordinalPositions.length; + + // remove duplicates (#1588) + ordinalPositions.sort(function (a, b) { + return a - b; // without a custom function it is sorted as strings + }); + + if (len) { + i = len - 1; + while (i--) { + if (ordinalPositions[i] === ordinalPositions[i + 1]) { + ordinalPositions.splice(i, 1); + } + } + } + } + + }); + + // cache the length + len = ordinalPositions.length; + + // Check if we really need the overhead of mapping axis data against the ordinal positions. + // If the series consist of evenly spaced data any way, we don't need any ordinal logic. + if (len > 2) { // two points have equal distance by default + dist = ordinalPositions[1] - ordinalPositions[0]; + i = len - 1; + while (i-- && !useOrdinal) { + if (ordinalPositions[i + 1] - ordinalPositions[i] !== dist) { + useOrdinal = true; + } + } + + // When zooming in on a week, prevent axis padding for weekends even though the data within + // the week is evenly spaced. + if (!axis.options.keepOrdinalPadding && (ordinalPositions[0] - min > dist || max - ordinalPositions[ordinalPositions.length - 1] > dist)) { + useOrdinal = true; + } + } + + // Record the slope and offset to compute the linear values from the array index. + // Since the ordinal positions may exceed the current range, get the start and + // end positions within it (#719, #665b) + if (useOrdinal) { + + // Register + axis.ordinalPositions = ordinalPositions; + + // This relies on the ordinalPositions being set. Use mathMax and mathMin to prevent + // padding on either sides of the data. + minIndex = axis.val2lin(mathMax(min, ordinalPositions[0]), true); + maxIndex = axis.val2lin(mathMin(max, ordinalPositions[ordinalPositions.length - 1]), true); + + // Set the slope and offset of the values compared to the indices in the ordinal positions + axis.ordinalSlope = slope = (max - min) / (maxIndex - minIndex); + axis.ordinalOffset = min - (minIndex * slope); + + } else { + axis.ordinalPositions = axis.ordinalSlope = axis.ordinalOffset = UNDEFINED; + } + } + axis.groupIntervalFactor = null; // reset for next run + }, + /** + * Translate from a linear axis value to the corresponding ordinal axis position. If there + * are no gaps in the ordinal axis this will be the same. The translated value is the value + * that the point would have if the axis were linear, using the same min and max. + * + * @param Number val The axis value + * @param Boolean toIndex Whether to return the index in the ordinalPositions or the new value + */ + val2lin: function (val, toIndex) { + var axis = this, + ordinalPositions = axis.ordinalPositions; + + if (!ordinalPositions) { + return val; + + } else { + + var ordinalLength = ordinalPositions.length, + i, + distance, + ordinalIndex; + + // first look for an exact match in the ordinalpositions array + i = ordinalLength; + while (i--) { + if (ordinalPositions[i] === val) { + ordinalIndex = i; + break; + } + } + + // if that failed, find the intermediate position between the two nearest values + i = ordinalLength - 1; + while (i--) { + if (val > ordinalPositions[i] || i === 0) { // interpolate + distance = (val - ordinalPositions[i]) / (ordinalPositions[i + 1] - ordinalPositions[i]); // something between 0 and 1 + ordinalIndex = i + distance; + break; + } + } + return toIndex ? + ordinalIndex : + axis.ordinalSlope * (ordinalIndex || 0) + axis.ordinalOffset; + } + }, + /** + * Translate from linear (internal) to axis value + * + * @param Number val The linear abstracted value + * @param Boolean fromIndex Translate from an index in the ordinal positions rather than a value + */ + lin2val: function (val, fromIndex) { + var axis = this, + ordinalPositions = axis.ordinalPositions; + + if (!ordinalPositions) { // the visible range contains only equally spaced values + return val; + + } else { + + var ordinalSlope = axis.ordinalSlope, + ordinalOffset = axis.ordinalOffset, + i = ordinalPositions.length - 1, + linearEquivalentLeft, + linearEquivalentRight, + distance; + + + // Handle the case where we translate from the index directly, used only + // when panning an ordinal axis + if (fromIndex) { + + if (val < 0) { // out of range, in effect panning to the left + val = ordinalPositions[0]; + } else if (val > i) { // out of range, panning to the right + val = ordinalPositions[i]; + } else { // split it up + i = mathFloor(val); + distance = val - i; // the decimal + } + + // Loop down along the ordinal positions. When the linear equivalent of i matches + // an ordinal position, interpolate between the left and right values. + } else { + while (i--) { + linearEquivalentLeft = (ordinalSlope * i) + ordinalOffset; + if (val >= linearEquivalentLeft) { + linearEquivalentRight = (ordinalSlope * (i + 1)) + ordinalOffset; + distance = (val - linearEquivalentLeft) / (linearEquivalentRight - linearEquivalentLeft); // something between 0 and 1 + break; + } + } + } + + // If the index is within the range of the ordinal positions, return the associated + // or interpolated value. If not, just return the value + return distance !== UNDEFINED && ordinalPositions[i] !== UNDEFINED ? + ordinalPositions[i] + (distance ? distance * (ordinalPositions[i + 1] - ordinalPositions[i]) : 0) : + val; + } + }, + /** + * Get the ordinal positions for the entire data set. This is necessary in chart panning + * because we need to find out what points or data groups are available outside the + * visible range. When a panning operation starts, if an index for the given grouping + * does not exists, it is created and cached. This index is deleted on updated data, so + * it will be regenerated the next time a panning operation starts. + */ + getExtendedPositions: function () { + var axis = this, + chart = axis.chart, + grouping = axis.series[0].currentDataGrouping, + ordinalIndex = axis.ordinalIndex, + key = grouping ? grouping.count + grouping.unitName : 'raw', + extremes = axis.getExtremes(), + fakeAxis, + fakeSeries; + + // If this is the first time, or the ordinal index is deleted by updatedData, + // create it. + if (!ordinalIndex) { + ordinalIndex = axis.ordinalIndex = {}; + } + + + if (!ordinalIndex[key]) { + + // Create a fake axis object where the extended ordinal positions are emulated + fakeAxis = { + series: [], + getExtremes: function () { + return { + min: extremes.dataMin, + max: extremes.dataMax + }; + }, + options: { + ordinal: true + }, + val2lin: Axis.prototype.val2lin // #2590 + }; + + // Add the fake series to hold the full data, then apply processData to it + each(axis.series, function (series) { + fakeSeries = { + xAxis: fakeAxis, + xData: series.xData, + chart: chart, + destroyGroupedData: noop + }; + fakeSeries.options = { + dataGrouping : grouping ? { + enabled: true, + forced: true, + approximation: 'open', // doesn't matter which, use the fastest + units: [[grouping.unitName, [grouping.count]]] + } : { + enabled: false + } + }; + series.processData.apply(fakeSeries); + + fakeAxis.series.push(fakeSeries); + }); + + // Run beforeSetTickPositions to compute the ordinalPositions + axis.beforeSetTickPositions.apply(fakeAxis); + + // Cache it + ordinalIndex[key] = fakeAxis.ordinalPositions; + } + return ordinalIndex[key]; + }, + + /** + * Find the factor to estimate how wide the plot area would have been if ordinal + * gaps were included. This value is used to compute an imagined plot width in order + * to establish the data grouping interval. + * + * A real world case is the intraday-candlestick + * example. Without this logic, it would show the correct data grouping when viewing + * a range within each day, but once moving the range to include the gap between two + * days, the interval would include the cut-away night hours and the data grouping + * would be wrong. So the below method tries to compensate by identifying the most + * common point interval, in this case days. + * + * An opposite case is presented in issue #718. We have a long array of daily data, + * then one point is appended one hour after the last point. We expect the data grouping + * not to change. + * + * In the future, if we find cases where this estimation doesn't work optimally, we + * might need to add a second pass to the data grouping logic, where we do another run + * with a greater interval if the number of data groups is more than a certain fraction + * of the desired group count. + */ + getGroupIntervalFactor: function (xMin, xMax, series) { + var i = 0, + processedXData = series.processedXData, + len = processedXData.length, + distances = [], + median, + groupIntervalFactor = this.groupIntervalFactor; + + // Only do this computation for the first series, let the other inherit it (#2416) + if (!groupIntervalFactor) { + + // Register all the distances in an array + for (; i < len - 1; i++) { + distances[i] = processedXData[i + 1] - processedXData[i]; + } + + // Sort them and find the median + distances.sort(function (a, b) { + return a - b; + }); + median = distances[mathFloor(len / 2)]; + + // Compensate for series that don't extend through the entire axis extent. #1675. + xMin = mathMax(xMin, processedXData[0]); + xMax = mathMin(xMax, processedXData[len - 1]); + + this.groupIntervalFactor = groupIntervalFactor = (len * median) / (xMax - xMin); + } + + // Return the factor needed for data grouping + return groupIntervalFactor; + }, + + /** + * Make the tick intervals closer because the ordinal gaps make the ticks spread out or cluster + */ + postProcessTickInterval: function (tickInterval) { + // TODO: http://jsfiddle.net/highcharts/FQm4E/1/ + // This is a case where this algorithm doesn't work optimally. In this case, the + // tick labels are spread out per week, but all the gaps reside within weeks. So + // we have a situation where the labels are courser than the ordinal gaps, and + // thus the tick interval should not be altered + var ordinalSlope = this.ordinalSlope; + + return ordinalSlope ? + tickInterval / (ordinalSlope / this.closestPointRange) : + tickInterval; + } +}); + +// Extending the Chart.pan method for ordinal axes +wrap(Chart.prototype, 'pan', function (proceed, e) { + var chart = this, + xAxis = chart.xAxis[0], + chartX = e.chartX, + runBase = false; + + if (xAxis.options.ordinal && xAxis.series.length) { + + var mouseDownX = chart.mouseDownX, + extremes = xAxis.getExtremes(), + dataMax = extremes.dataMax, + min = extremes.min, + max = extremes.max, + trimmedRange, + hoverPoints = chart.hoverPoints, + closestPointRange = xAxis.closestPointRange, + pointPixelWidth = xAxis.translationSlope * (xAxis.ordinalSlope || closestPointRange), + movedUnits = (mouseDownX - chartX) / pointPixelWidth, // how many ordinal units did we move? + extendedAxis = { ordinalPositions: xAxis.getExtendedPositions() }, // get index of all the chart's points + ordinalPositions, + searchAxisLeft, + lin2val = xAxis.lin2val, + val2lin = xAxis.val2lin, + searchAxisRight; + + if (!extendedAxis.ordinalPositions) { // we have an ordinal axis, but the data is equally spaced + runBase = true; + + } else if (mathAbs(movedUnits) > 1) { + + // Remove active points for shared tooltip + if (hoverPoints) { + each(hoverPoints, function (point) { + point.setState(); + }); + } + + if (movedUnits < 0) { + searchAxisLeft = extendedAxis; + searchAxisRight = xAxis.ordinalPositions ? xAxis : extendedAxis; + } else { + searchAxisLeft = xAxis.ordinalPositions ? xAxis : extendedAxis; + searchAxisRight = extendedAxis; + } + + // In grouped data series, the last ordinal position represents the grouped data, which is + // to the left of the real data max. If we don't compensate for this, we will be allowed + // to pan grouped data series passed the right of the plot area. + ordinalPositions = searchAxisRight.ordinalPositions; + if (dataMax > ordinalPositions[ordinalPositions.length - 1]) { + ordinalPositions.push(dataMax); + } + + // Get the new min and max values by getting the ordinal index for the current extreme, + // then add the moved units and translate back to values. This happens on the + // extended ordinal positions if the new position is out of range, else it happens + // on the current x axis which is smaller and faster. + chart.fixedRange = max - min; + trimmedRange = xAxis.toFixedRange(null, null, + lin2val.apply(searchAxisLeft, [ + val2lin.apply(searchAxisLeft, [min, true]) + movedUnits, // the new index + true // translate from index + ]), + lin2val.apply(searchAxisRight, [ + val2lin.apply(searchAxisRight, [max, true]) + movedUnits, // the new index + true // translate from index + ]) + ); + + // Apply it if it is within the available data range + if (trimmedRange.min >= mathMin(extremes.dataMin, min) && trimmedRange.max <= mathMax(dataMax, max)) { + xAxis.setExtremes(trimmedRange.min, trimmedRange.max, true, false, { trigger: 'pan' }); + } + + chart.mouseDownX = chartX; // set new reference for next run + css(chart.container, { cursor: 'move' }); + } + + } else { + runBase = true; + } + + // revert to the linear chart.pan version + if (runBase) { + // call the original function + proceed.apply(this, Array.prototype.slice.call(arguments, 1)); + } +}); + + + +/** + * Extend getSegments by identifying gaps in the ordinal data so that we can draw a gap in the + * line or area + */ +wrap(Series.prototype, 'getSegments', function (proceed) { + + var series = this, + segments, + gapSize = series.options.gapSize, + xAxis = series.xAxis; + + // call base method + proceed.apply(this, Array.prototype.slice.call(arguments, 1)); + + if (gapSize) { + + // properties + segments = series.segments; + + // extension for ordinal breaks + each(segments, function (segment, no) { + var i = segment.length - 1; + while (i--) { + if (segment[i + 1].x - segment[i].x > xAxis.closestPointRange * gapSize) { + segments.splice( // insert after this one + no + 1, + 0, + segment.splice(i + 1, segment.length - i) + ); + } + } + }); + } +}); + +/* **************************************************************************** + * End ordinal axis logic * + *****************************************************************************/ +/* **************************************************************************** + * Start data grouping module * + ******************************************************************************/ +/*jslint white:true */ +var DATA_GROUPING = 'dataGrouping', + seriesProto = Series.prototype, + tooltipProto = Tooltip.prototype, + baseProcessData = seriesProto.processData, + baseGeneratePoints = seriesProto.generatePoints, + baseDestroy = seriesProto.destroy, + baseTooltipHeaderFormatter = tooltipProto.tooltipHeaderFormatter, + NUMBER = 'number', + + commonOptions = { + approximation: 'average', // average, open, high, low, close, sum + //enabled: null, // (true for stock charts, false for basic), + //forced: undefined, + groupPixelWidth: 2, + // the first one is the point or start value, the second is the start value if we're dealing with range, + // the third one is the end value if dealing with a range + dateTimeLabelFormats: { + millisecond: ['%A, %b %e, %H:%M:%S.%L', '%A, %b %e, %H:%M:%S.%L', '-%H:%M:%S.%L'], + second: ['%A, %b %e, %H:%M:%S', '%A, %b %e, %H:%M:%S', '-%H:%M:%S'], + minute: ['%A, %b %e, %H:%M', '%A, %b %e, %H:%M', '-%H:%M'], + hour: ['%A, %b %e, %H:%M', '%A, %b %e, %H:%M', '-%H:%M'], + day: ['%A, %b %e, %Y', '%A, %b %e', '-%A, %b %e, %Y'], + week: ['Week from %A, %b %e, %Y', '%A, %b %e', '-%A, %b %e, %Y'], + month: ['%B %Y', '%B', '-%B %Y'], + year: ['%Y', '%Y', '-%Y'] + } + // smoothed = false, // enable this for navigator series only + }, + + specificOptions = { // extends common options + line: {}, + spline: {}, + area: {}, + areaspline: {}, + column: { + approximation: 'sum', + groupPixelWidth: 10 + }, + arearange: { + approximation: 'range' + }, + areasplinerange: { + approximation: 'range' + }, + columnrange: { + approximation: 'range', + groupPixelWidth: 10 + }, + candlestick: { + approximation: 'ohlc', + groupPixelWidth: 10 + }, + ohlc: { + approximation: 'ohlc', + groupPixelWidth: 5 + } + }, + + // units are defined in a separate array to allow complete overriding in case of a user option + defaultDataGroupingUnits = [[ + 'millisecond', // unit name + [1, 2, 5, 10, 20, 25, 50, 100, 200, 500] // allowed multiples + ], [ + 'second', + [1, 2, 5, 10, 15, 30] + ], [ + 'minute', + [1, 2, 5, 10, 15, 30] + ], [ + 'hour', + [1, 2, 3, 4, 6, 8, 12] + ], [ + 'day', + [1] + ], [ + 'week', + [1] + ], [ + 'month', + [1, 3, 6] + ], [ + 'year', + null + ] + ], + + + /** + * Define the available approximation types. The data grouping approximations takes an array + * or numbers as the first parameter. In case of ohlc, four arrays are sent in as four parameters. + * Each array consists only of numbers. In case null values belong to the group, the property + * .hasNulls will be set to true on the array. + */ + approximations = { + sum: function (arr) { + var len = arr.length, + ret; + + // 1. it consists of nulls exclusively + if (!len && arr.hasNulls) { + ret = null; + // 2. it has a length and real values + } else if (len) { + ret = 0; + while (len--) { + ret += arr[len]; + } + } + // 3. it has zero length, so just return undefined + // => doNothing() + + return ret; + }, + average: function (arr) { + var len = arr.length, + ret = approximations.sum(arr); + + // If we have a number, return it divided by the length. If not, return + // null or undefined based on what the sum method finds. + if (typeof ret === NUMBER && len) { + ret = ret / len; + } + + return ret; + }, + open: function (arr) { + return arr.length ? arr[0] : (arr.hasNulls ? null : UNDEFINED); + }, + high: function (arr) { + return arr.length ? arrayMax(arr) : (arr.hasNulls ? null : UNDEFINED); + }, + low: function (arr) { + return arr.length ? arrayMin(arr) : (arr.hasNulls ? null : UNDEFINED); + }, + close: function (arr) { + return arr.length ? arr[arr.length - 1] : (arr.hasNulls ? null : UNDEFINED); + }, + // ohlc and range are special cases where a multidimensional array is input and an array is output + ohlc: function (open, high, low, close) { + open = approximations.open(open); + high = approximations.high(high); + low = approximations.low(low); + close = approximations.close(close); + + if (typeof open === NUMBER || typeof high === NUMBER || typeof low === NUMBER || typeof close === NUMBER) { + return [open, high, low, close]; + } + // else, return is undefined + }, + range: function (low, high) { + low = approximations.low(low); + high = approximations.high(high); + + if (typeof low === NUMBER || typeof high === NUMBER) { + return [low, high]; + } + // else, return is undefined + } + }; + +/*jslint white:false */ + +/** + * Takes parallel arrays of x and y data and groups the data into intervals defined by groupPositions, a collection + * of starting x values for each group. + */ +seriesProto.groupData = function (xData, yData, groupPositions, approximation) { + var series = this, + data = series.data, + dataOptions = series.options.data, + groupedXData = [], + groupedYData = [], + dataLength = xData.length, + pointX, + pointY, + groupedY, + handleYData = !!yData, // when grouping the fake extended axis for panning, we don't need to consider y + values = [[], [], [], []], + approximationFn = typeof approximation === 'function' ? approximation : approximations[approximation], + pointArrayMap = series.pointArrayMap, + pointArrayMapLength = pointArrayMap && pointArrayMap.length, + i; + + // Start with the first point within the X axis range (#2696) + for (i = 0; i <= dataLength; i++) { + if (xData[i] >= groupPositions[0]) { + break; + } + } + + for (; i <= dataLength; i++) { + + // when a new group is entered, summarize and initiate the previous group + while ((groupPositions[1] !== UNDEFINED && xData[i] >= groupPositions[1]) || + i === dataLength) { // get the last group + + // get group x and y + pointX = groupPositions.shift(); + groupedY = approximationFn.apply(0, values); + + // push the grouped data + if (groupedY !== UNDEFINED) { + groupedXData.push(pointX); + groupedYData.push(groupedY); + } + + // reset the aggregate arrays + values[0] = []; + values[1] = []; + values[2] = []; + values[3] = []; + + // don't loop beyond the last group + if (i === dataLength) { + break; + } + } + + // break out + if (i === dataLength) { + break; + } + + // for each raw data point, push it to an array that contains all values for this specific group + if (pointArrayMap) { + + var index = series.cropStart + i, + point = (data && data[index]) || series.pointClass.prototype.applyOptions.apply({ series: series }, [dataOptions[index]]), + j, + val; + + for (j = 0; j < pointArrayMapLength; j++) { + val = point[pointArrayMap[j]]; + if (typeof val === NUMBER) { + values[j].push(val); + } else if (val === null) { + values[j].hasNulls = true; + } + } + + } else { + pointY = handleYData ? yData[i] : null; + + if (typeof pointY === NUMBER) { + values[0].push(pointY); + } else if (pointY === null) { + values[0].hasNulls = true; + } + } + } + + return [groupedXData, groupedYData]; +}; + +/** + * Extend the basic processData method, that crops the data to the current zoom + * range, with data grouping logic. + */ +seriesProto.processData = function () { + var series = this, + chart = series.chart, + options = series.options, + dataGroupingOptions = options[DATA_GROUPING], + groupingEnabled = series.allowDG !== false && dataGroupingOptions && pick(dataGroupingOptions.enabled, chart.options._stock), + hasGroupedData; + + // run base method + series.forceCrop = groupingEnabled; // #334 + series.groupPixelWidth = null; // #2110 + series.hasProcessed = true; // #2692 + + // skip if processData returns false or if grouping is disabled (in that order) + if (baseProcessData.apply(series, arguments) === false || !groupingEnabled) { + return; + + } else { + series.destroyGroupedData(); + + } + var i, + processedXData = series.processedXData, + processedYData = series.processedYData, + plotSizeX = chart.plotSizeX, + xAxis = series.xAxis, + ordinal = xAxis.options.ordinal, + groupPixelWidth = series.groupPixelWidth = xAxis.getGroupPixelWidth && xAxis.getGroupPixelWidth(), + nonGroupedPointRange = series.pointRange; + + // Execute grouping if the amount of points is greater than the limit defined in groupPixelWidth + if (groupPixelWidth) { + hasGroupedData = true; + + series.points = null; // force recreation of point instances in series.translate + + var extremes = xAxis.getExtremes(), + xMin = extremes.min, + xMax = extremes.max, + groupIntervalFactor = (ordinal && xAxis.getGroupIntervalFactor(xMin, xMax, series)) || 1, + interval = (groupPixelWidth * (xMax - xMin) / plotSizeX) * groupIntervalFactor, + groupPositions = xAxis.getTimeTicks( + xAxis.normalizeTimeTickInterval(interval, dataGroupingOptions.units || defaultDataGroupingUnits), + xMin, + xMax, + null, + processedXData, + series.closestPointRange + ), + groupedXandY = seriesProto.groupData.apply(series, [processedXData, processedYData, groupPositions, dataGroupingOptions.approximation]), + groupedXData = groupedXandY[0], + groupedYData = groupedXandY[1]; + + // prevent the smoothed data to spill out left and right, and make + // sure data is not shifted to the left + if (dataGroupingOptions.smoothed) { + i = groupedXData.length - 1; + groupedXData[i] = xMax; + while (i-- && i > 0) { + groupedXData[i] += interval / 2; + } + groupedXData[0] = xMin; + } + + // record what data grouping values were used + series.currentDataGrouping = groupPositions.info; + if (options.pointRange === null) { // null means auto, as for columns, candlesticks and OHLC + series.pointRange = groupPositions.info.totalRange; + } + series.closestPointRange = groupPositions.info.totalRange; + + // Make sure the X axis extends to show the first group (#2533) + if (defined(groupedXData[0]) && groupedXData[0] < xAxis.dataMin) { + xAxis.dataMin = groupedXData[0]; + } + + // set series props + series.processedXData = groupedXData; + series.processedYData = groupedYData; + } else { + series.currentDataGrouping = null; + series.pointRange = nonGroupedPointRange; + } + series.hasGroupedData = hasGroupedData; +}; + +/** + * Destroy the grouped data points. #622, #740 + */ +seriesProto.destroyGroupedData = function () { + + var groupedData = this.groupedData; + + // clear previous groups + each(groupedData || [], function (point, i) { + if (point) { + groupedData[i] = point.destroy ? point.destroy() : null; + } + }); + this.groupedData = null; +}; + +/** + * Override the generatePoints method by adding a reference to grouped data + */ +seriesProto.generatePoints = function () { + + baseGeneratePoints.apply(this); + + // record grouped data in order to let it be destroyed the next time processData runs + this.destroyGroupedData(); // #622 + this.groupedData = this.hasGroupedData ? this.points : null; +}; + +/** + * Extend the original method, make the tooltip's header reflect the grouped range + */ +tooltipProto.tooltipHeaderFormatter = function (point) { + var tooltip = this, + series = point.series, + options = series.options, + tooltipOptions = series.tooltipOptions, + dataGroupingOptions = options.dataGrouping, + xDateFormat = tooltipOptions.xDateFormat, + xDateFormatEnd, + xAxis = series.xAxis, + currentDataGrouping, + dateTimeLabelFormats, + labelFormats, + formattedKey, + n, + ret; + + // apply only to grouped series + if (xAxis && xAxis.options.type === 'datetime' && dataGroupingOptions && isNumber(point.key)) { + + // set variables + currentDataGrouping = series.currentDataGrouping; + dateTimeLabelFormats = dataGroupingOptions.dateTimeLabelFormats; + + // if we have grouped data, use the grouping information to get the right format + if (currentDataGrouping) { + labelFormats = dateTimeLabelFormats[currentDataGrouping.unitName]; + if (currentDataGrouping.count === 1) { + xDateFormat = labelFormats[0]; + } else { + xDateFormat = labelFormats[1]; + xDateFormatEnd = labelFormats[2]; + } + // if not grouped, and we don't have set the xDateFormat option, get the best fit, + // so if the least distance between points is one minute, show it, but if the + // least distance is one day, skip hours and minutes etc. + } else if (!xDateFormat && dateTimeLabelFormats) { + for (n in timeUnits) { + if (timeUnits[n] >= xAxis.closestPointRange || + // If the point is placed every day at 23:59, we need to show + // the minutes as well. This logic only works for time units less than + // a day, since all higher time units are dividable by those. #2637. + (timeUnits[n] <= timeUnits.day && point.key % timeUnits[n] > 0)) { + + xDateFormat = dateTimeLabelFormats[n][0]; + break; + } + } + } + + // now format the key + formattedKey = dateFormat(xDateFormat, point.key); + if (xDateFormatEnd) { + formattedKey += dateFormat(xDateFormatEnd, point.key + currentDataGrouping.totalRange - 1); + } + + // return the replaced format + ret = tooltipOptions.headerFormat.replace('{point.key}', formattedKey); + + // else, fall back to the regular formatter + } else { + ret = baseTooltipHeaderFormatter.call(tooltip, point); + } + + return ret; +}; + +/** + * Extend the series destroyer + */ +seriesProto.destroy = function () { + var series = this, + groupedData = series.groupedData || [], + i = groupedData.length; + + while (i--) { + if (groupedData[i]) { + groupedData[i].destroy(); + } + } + baseDestroy.apply(series); +}; + + +// Handle default options for data grouping. This must be set at runtime because some series types are +// defined after this. +wrap(seriesProto, 'setOptions', function (proceed, itemOptions) { + + var options = proceed.call(this, itemOptions), + type = this.type, + plotOptions = this.chart.options.plotOptions, + defaultOptions = defaultPlotOptions[type].dataGrouping; + + if (specificOptions[type]) { // #1284 + if (!defaultOptions) { + defaultOptions = merge(commonOptions, specificOptions[type]); + } + + options.dataGrouping = merge( + defaultOptions, + plotOptions.series && plotOptions.series.dataGrouping, // #1228 + plotOptions[type].dataGrouping, // Set by the StockChart constructor + itemOptions.dataGrouping + ); + } + + if (this.chart.options._stock) { + this.requireSorting = true; + } + + return options; +}); + + +/** + * When resetting the scale reset the hasProccessed flag to avoid taking previous data grouping + * of neighbour series into accound when determining group pixel width (#2692). + */ +wrap(Axis.prototype, 'setScale', function (proceed) { + proceed.call(this); + each(this.series, function (series) { + series.hasProcessed = false; + }); +}); + +/** + * Get the data grouping pixel width based on the greatest defined individual width + * of the axis' series, and if whether one of the axes need grouping. + */ +Axis.prototype.getGroupPixelWidth = function () { + + var series = this.series, + len = series.length, + i, + groupPixelWidth = 0, + doGrouping = false, + dataLength, + dgOptions; + + // If multiple series are compared on the same x axis, give them the same + // group pixel width (#334) + i = len; + while (i--) { + dgOptions = series[i].options.dataGrouping; + if (dgOptions) { + groupPixelWidth = mathMax(groupPixelWidth, dgOptions.groupPixelWidth); + + } + } + + // If one of the series needs grouping, apply it to all (#1634) + i = len; + while (i--) { + dgOptions = series[i].options.dataGrouping; + + if (dgOptions && series[i].hasProcessed) { // #2692 + + dataLength = (series[i].processedXData || series[i].data).length; + + // Execute grouping if the amount of points is greater than the limit defined in groupPixelWidth + if (series[i].groupPixelWidth || dataLength > (this.chart.plotSizeX / groupPixelWidth) || (dataLength && dgOptions.forced)) { + doGrouping = true; + } + } + } + + return doGrouping ? groupPixelWidth : 0; +}; + + + +/* **************************************************************************** + * End data grouping module * + ******************************************************************************//* **************************************************************************** + * Start OHLC series code * + *****************************************************************************/ + +// 1 - Set default options +defaultPlotOptions.ohlc = merge(defaultPlotOptions.column, { + lineWidth: 1, + tooltip: { + pointFormat: '\u25CF {series.name}
' + + 'Open: {point.open}
' + + 'High: {point.high}
' + + 'Low: {point.low}
' + + 'Close: {point.close}
' + }, + states: { + hover: { + lineWidth: 3 + } + }, + threshold: null + //upColor: undefined +}); + +// 2 - Create the OHLCSeries object +var OHLCSeries = extendClass(seriesTypes.column, { + type: 'ohlc', + pointArrayMap: ['open', 'high', 'low', 'close'], // array point configs are mapped to this + toYData: function (point) { // return a plain array for speedy calculation + return [point.open, point.high, point.low, point.close]; + }, + pointValKey: 'high', + + pointAttrToOptions: { // mapping between SVG attributes and the corresponding options + stroke: 'color', + 'stroke-width': 'lineWidth' + }, + upColorProp: 'stroke', + + /** + * Postprocess mapping between options and SVG attributes + */ + getAttribs: function () { + seriesTypes.column.prototype.getAttribs.apply(this, arguments); + var series = this, + options = series.options, + stateOptions = options.states, + upColor = options.upColor || series.color, + seriesDownPointAttr = merge(series.pointAttr), + upColorProp = series.upColorProp; + + seriesDownPointAttr[''][upColorProp] = upColor; + seriesDownPointAttr.hover[upColorProp] = stateOptions.hover.upColor || upColor; + seriesDownPointAttr.select[upColorProp] = stateOptions.select.upColor || upColor; + + each(series.points, function (point) { + if (point.open < point.close) { + point.pointAttr = seriesDownPointAttr; + } + }); + }, + + /** + * Translate data points from raw values x and y to plotX and plotY + */ + translate: function () { + var series = this, + yAxis = series.yAxis; + + seriesTypes.column.prototype.translate.apply(series); + + // do the translation + each(series.points, function (point) { + // the graphics + if (point.open !== null) { + point.plotOpen = yAxis.translate(point.open, 0, 1, 0, 1); + } + if (point.close !== null) { + point.plotClose = yAxis.translate(point.close, 0, 1, 0, 1); + } + + }); + }, + + /** + * Draw the data points + */ + drawPoints: function () { + var series = this, + points = series.points, + chart = series.chart, + pointAttr, + plotOpen, + plotClose, + crispCorr, + halfWidth, + path, + graphic, + crispX; + + + each(points, function (point) { + if (point.plotY !== UNDEFINED) { + + graphic = point.graphic; + pointAttr = point.pointAttr[point.selected ? 'selected' : ''] || series.pointAttr[NORMAL_STATE]; + + // crisp vector coordinates + crispCorr = (pointAttr['stroke-width'] % 2) / 2; + crispX = mathRound(point.plotX) - crispCorr; // #2596 + halfWidth = mathRound(point.shapeArgs.width / 2); + + // the vertical stem + path = [ + 'M', + crispX, mathRound(point.yBottom), + 'L', + crispX, mathRound(point.plotY) + ]; + + // open + if (point.open !== null) { + plotOpen = mathRound(point.plotOpen) + crispCorr; + path.push( + 'M', + crispX, + plotOpen, + 'L', + crispX - halfWidth, + plotOpen + ); + } + + // close + if (point.close !== null) { + plotClose = mathRound(point.plotClose) + crispCorr; + path.push( + 'M', + crispX, + plotClose, + 'L', + crispX + halfWidth, + plotClose + ); + } + + // create and/or update the graphic + if (graphic) { + graphic.animate({ d: path }); + } else { + point.graphic = chart.renderer.path(path) + .attr(pointAttr) + .add(series.group); + } + + } + + + }); + + }, + + /** + * Disable animation + */ + animate: null + + +}); +seriesTypes.ohlc = OHLCSeries; +/* **************************************************************************** + * End OHLC series code * + *****************************************************************************/ +/* **************************************************************************** + * Start Candlestick series code * + *****************************************************************************/ + +// 1 - set default options +defaultPlotOptions.candlestick = merge(defaultPlotOptions.column, { + lineColor: 'black', + lineWidth: 1, + states: { + hover: { + lineWidth: 2 + } + }, + tooltip: defaultPlotOptions.ohlc.tooltip, + threshold: null, + upColor: 'white' + // upLineColor: null +}); + +// 2 - Create the CandlestickSeries object +var CandlestickSeries = extendClass(OHLCSeries, { + type: 'candlestick', + + /** + * One-to-one mapping from options to SVG attributes + */ + pointAttrToOptions: { // mapping between SVG attributes and the corresponding options + fill: 'color', + stroke: 'lineColor', + 'stroke-width': 'lineWidth' + }, + upColorProp: 'fill', + + /** + * Postprocess mapping between options and SVG attributes + */ + getAttribs: function () { + seriesTypes.ohlc.prototype.getAttribs.apply(this, arguments); + var series = this, + options = series.options, + stateOptions = options.states, + upLineColor = options.upLineColor || options.lineColor, + hoverStroke = stateOptions.hover.upLineColor || upLineColor, + selectStroke = stateOptions.select.upLineColor || upLineColor; + + // Add custom line color for points going up (close > open). + // Fill is handled by OHLCSeries' getAttribs. + each(series.points, function (point) { + if (point.open < point.close) { + point.pointAttr[''].stroke = upLineColor; + point.pointAttr.hover.stroke = hoverStroke; + point.pointAttr.select.stroke = selectStroke; + } + }); + }, + + /** + * Draw the data points + */ + drawPoints: function () { + var series = this, //state = series.state, + points = series.points, + chart = series.chart, + pointAttr, + seriesPointAttr = series.pointAttr[''], + plotOpen, + plotClose, + topBox, + bottomBox, + hasTopWhisker, + hasBottomWhisker, + crispCorr, + crispX, + graphic, + path, + halfWidth; + + + each(points, function (point) { + + graphic = point.graphic; + if (point.plotY !== UNDEFINED) { + + pointAttr = point.pointAttr[point.selected ? 'selected' : ''] || seriesPointAttr; + + // crisp vector coordinates + crispCorr = (pointAttr['stroke-width'] % 2) / 2; + crispX = mathRound(point.plotX) - crispCorr; // #2596 + plotOpen = point.plotOpen; + plotClose = point.plotClose; + topBox = math.min(plotOpen, plotClose); + bottomBox = math.max(plotOpen, plotClose); + halfWidth = mathRound(point.shapeArgs.width / 2); + hasTopWhisker = mathRound(topBox) !== mathRound(point.plotY); + hasBottomWhisker = bottomBox !== point.yBottom; + topBox = mathRound(topBox) + crispCorr; + bottomBox = mathRound(bottomBox) + crispCorr; + + // create the path + path = [ + 'M', + crispX - halfWidth, bottomBox, + 'L', + crispX - halfWidth, topBox, + 'L', + crispX + halfWidth, topBox, + 'L', + crispX + halfWidth, bottomBox, + 'Z', // Use a close statement to ensure a nice rectangle #2602 + 'M', + crispX, topBox, + 'L', + crispX, hasTopWhisker ? mathRound(point.plotY) : topBox, // #460, #2094 + 'M', + crispX, bottomBox, + 'L', + crispX, hasBottomWhisker ? mathRound(point.yBottom) : bottomBox // #460, #2094 + ]; + + if (graphic) { + graphic.animate({ d: path }); + } else { + point.graphic = chart.renderer.path(path) + .attr(pointAttr) + .add(series.group) + .shadow(series.options.shadow); + } + + } + }); + + } + + +}); + +seriesTypes.candlestick = CandlestickSeries; + +/* **************************************************************************** + * End Candlestick series code * + *****************************************************************************/ +/* **************************************************************************** + * Start Flags series code * + *****************************************************************************/ + +var symbols = SVGRenderer.prototype.symbols; + +// 1 - set default options +defaultPlotOptions.flags = merge(defaultPlotOptions.column, { + fillColor: 'white', + lineWidth: 1, + pointRange: 0, // #673 + //radius: 2, + shape: 'flag', + stackDistance: 12, + states: { + hover: { + lineColor: 'black', + fillColor: '#FCFFC5' + } + }, + style: { + fontSize: '11px', + fontWeight: 'bold', + textAlign: 'center' + }, + tooltip: { + pointFormat: '{point.text}
' + }, + threshold: null, + y: -30 +}); + +// 2 - Create the CandlestickSeries object +seriesTypes.flags = extendClass(seriesTypes.column, { + type: 'flags', + sorted: false, + noSharedTooltip: true, + allowDG: false, + takeOrdinalPosition: false, // #1074 + trackerGroups: ['markerGroup'], + forceCrop: true, + /** + * Inherit the initialization from base Series + */ + init: Series.prototype.init, + + /** + * One-to-one mapping from options to SVG attributes + */ + pointAttrToOptions: { // mapping between SVG attributes and the corresponding options + fill: 'fillColor', + stroke: 'color', + 'stroke-width': 'lineWidth', + r: 'radius' + }, + + /** + * Extend the translate method by placing the point on the related series + */ + translate: function () { + + seriesTypes.column.prototype.translate.apply(this); + + var series = this, + options = series.options, + chart = series.chart, + points = series.points, + cursor = points.length - 1, + point, + lastPoint, + optionsOnSeries = options.onSeries, + onSeries = optionsOnSeries && chart.get(optionsOnSeries), + step = onSeries && onSeries.options.step, + onData = onSeries && onSeries.points, + i = onData && onData.length, + xAxis = series.xAxis, + xAxisExt = xAxis.getExtremes(), + leftPoint, + lastX, + rightPoint, + currentDataGrouping; + + // relate to a master series + if (onSeries && onSeries.visible && i) { + currentDataGrouping = onSeries.currentDataGrouping; + lastX = onData[i - 1].x + (currentDataGrouping ? currentDataGrouping.totalRange : 0); // #2374 + + // sort the data points + points.sort(function (a, b) { + return (a.x - b.x); + }); + + while (i-- && points[cursor]) { + point = points[cursor]; + leftPoint = onData[i]; + + if (leftPoint.x <= point.x && leftPoint.plotY !== UNDEFINED) { + if (point.x <= lastX) { // #803 + + point.plotY = leftPoint.plotY; + + // interpolate between points, #666 + if (leftPoint.x < point.x && !step) { + rightPoint = onData[i + 1]; + if (rightPoint && rightPoint.plotY !== UNDEFINED) { + point.plotY += + ((point.x - leftPoint.x) / (rightPoint.x - leftPoint.x)) * // the distance ratio, between 0 and 1 + (rightPoint.plotY - leftPoint.plotY); // the y distance + } + } + } + cursor--; + i++; // check again for points in the same x position + if (cursor < 0) { + break; + } + } + } + } + + // Add plotY position and handle stacking + each(points, function (point, i) { + + // Undefined plotY means the point is either on axis, outside series range or hidden series. + // If the series is outside the range of the x axis it should fall through with + // an undefined plotY, but then we must remove the shapeArgs (#847). + if (point.plotY === UNDEFINED) { + if (point.x >= xAxisExt.min && point.x <= xAxisExt.max) { // we're inside xAxis range + point.plotY = chart.chartHeight - xAxis.bottom - (xAxis.opposite ? xAxis.height : 0) + xAxis.offset - chart.plotTop; + } else { + point.shapeArgs = {}; // 847 + } + } + // if multiple flags appear at the same x, order them into a stack + lastPoint = points[i - 1]; + if (lastPoint && lastPoint.plotX === point.plotX) { + if (lastPoint.stackIndex === UNDEFINED) { + lastPoint.stackIndex = 0; + } + point.stackIndex = lastPoint.stackIndex + 1; + } + + }); + + + }, + + /** + * Draw the markers + */ + drawPoints: function () { + var series = this, + pointAttr, + seriesPointAttr = series.pointAttr[''], + points = series.points, + chart = series.chart, + renderer = chart.renderer, + plotX, + plotY, + options = series.options, + optionsY = options.y, + shape, + i, + point, + graphic, + stackIndex, + crisp = (options.lineWidth % 2 / 2), + anchorX, + anchorY, + outsideRight; + + i = points.length; + while (i--) { + point = points[i]; + outsideRight = point.plotX > series.xAxis.len; + plotX = point.plotX + (outsideRight ? crisp : -crisp); + stackIndex = point.stackIndex; + shape = point.options.shape || options.shape; + plotY = point.plotY; + if (plotY !== UNDEFINED) { + plotY = point.plotY + optionsY + crisp - (stackIndex !== UNDEFINED && stackIndex * options.stackDistance); + } + anchorX = stackIndex ? UNDEFINED : point.plotX + crisp; // skip connectors for higher level stacked points + anchorY = stackIndex ? UNDEFINED : point.plotY; + + graphic = point.graphic; + + // only draw the point if y is defined and the flag is within the visible area + if (plotY !== UNDEFINED && plotX >= 0 && !outsideRight) { + // shortcuts + pointAttr = point.pointAttr[point.selected ? 'select' : ''] || seriesPointAttr; + if (graphic) { // update + graphic.attr({ + x: plotX, + y: plotY, + r: pointAttr.r, + anchorX: anchorX, + anchorY: anchorY + }); + } else { + graphic = point.graphic = renderer.label( + point.options.title || options.title || 'A', + plotX, + plotY, + shape, + anchorX, + anchorY, + options.useHTML + ) + .css(merge(options.style, point.style)) + .attr(pointAttr) + .attr({ + align: shape === 'flag' ? 'left' : 'center', + width: options.width, + height: options.height + }) + .add(series.markerGroup) + .shadow(options.shadow); + + } + + // Set the tooltip anchor position + point.tooltipPos = [plotX, plotY]; + + } else if (graphic) { + point.graphic = graphic.destroy(); + } + + } + + }, + + /** + * Extend the column trackers with listeners to expand and contract stacks + */ + drawTracker: function () { + var series = this, + points = series.points; + + TrackerMixin.drawTrackerPoint.apply(this); + + // Bring each stacked flag up on mouse over, this allows readability of vertically + // stacked elements as well as tight points on the x axis. #1924. + each(points, function (point) { + var graphic = point.graphic; + if (graphic) { + addEvent(graphic.element, 'mouseover', function () { + + // Raise this point + if (point.stackIndex > 0 && !point.raised) { + point._y = graphic.y; + graphic.attr({ + y: point._y - 8 + }); + point.raised = true; + } + + // Revert other raised points + each(points, function (otherPoint) { + if (otherPoint !== point && otherPoint.raised && otherPoint.graphic) { + otherPoint.graphic.attr({ + y: otherPoint._y + }); + otherPoint.raised = false; + } + }); + }); + } + }); + }, + + /** + * Disable animation + */ + animate: noop + +}); + +// create the flag icon with anchor +symbols.flag = function (x, y, w, h, options) { + var anchorX = (options && options.anchorX) || x, + anchorY = (options && options.anchorY) || y; + + return [ + 'M', anchorX, anchorY, + 'L', x, y + h, + x, y, + x + w, y, + x + w, y + h, + x, y + h, + 'M', anchorX, anchorY, + 'Z' + ]; +}; + +// create the circlepin and squarepin icons with anchor +each(['circle', 'square'], function (shape) { + symbols[shape + 'pin'] = function (x, y, w, h, options) { + + var anchorX = options && options.anchorX, + anchorY = options && options.anchorY, + path = symbols[shape](x, y, w, h), + labelTopOrBottomY; + + if (anchorX && anchorY) { + // if the label is below the anchor, draw the connecting line from the top edge of the label + // otherwise start drawing from the bottom edge + labelTopOrBottomY = (y > anchorY) ? y : y + h; + path.push('M', anchorX, labelTopOrBottomY, 'L', anchorX, anchorY); + } + + return path; + }; +}); + +// The symbol callbacks are generated on the SVGRenderer object in all browsers. Even +// VML browsers need this in order to generate shapes in export. Now share +// them with the VMLRenderer. +if (Renderer === Highcharts.VMLRenderer) { + each(['flag', 'circlepin', 'squarepin'], function (shape) { + VMLRenderer.prototype.symbols[shape] = symbols[shape]; + }); +} + +/* **************************************************************************** + * End Flags series code * + *****************************************************************************/ +/* **************************************************************************** + * Start Scroller code * + *****************************************************************************/ +var units = [].concat(defaultDataGroupingUnits), // copy + defaultSeriesType, + + // Finding the min or max of a set of variables where we don't know if they are defined, + // is a pattern that is repeated several places in Highcharts. Consider making this + // a global utility method. + numExt = function (extreme) { + return Math[extreme].apply(0, grep(arguments, function (n) { return typeof n === 'number'; })); + }; + +// add more resolution to units +units[4] = ['day', [1, 2, 3, 4]]; // allow more days +units[5] = ['week', [1, 2, 3]]; // allow more weeks + +defaultSeriesType = seriesTypes.areaspline === UNDEFINED ? 'line' : 'areaspline'; + +extend(defaultOptions, { + navigator: { + //enabled: true, + handles: { + backgroundColor: '#ebe7e8', + borderColor: '#b2b1b6' + }, + height: 40, + margin: 25, + maskFill: 'rgba(128,179,236,0.3)', + maskInside: true, + outlineColor: '#b2b1b6', + outlineWidth: 1, + series: { + type: defaultSeriesType, + color: '#4572A7', + compare: null, + fillOpacity: 0.05, + dataGrouping: { + approximation: 'average', + enabled: true, + groupPixelWidth: 2, + smoothed: true, + units: units + }, + dataLabels: { + enabled: false, + zIndex: 2 // #1839 + }, + id: PREFIX + 'navigator-series', + lineColor: '#4572A7', + lineWidth: 1, + marker: { + enabled: false + }, + pointRange: 0, + shadow: false, + threshold: null + }, + //top: undefined, + xAxis: { + tickWidth: 0, + lineWidth: 0, + gridLineColor: '#EEE', + gridLineWidth: 1, + tickPixelInterval: 200, + labels: { + align: 'left', + style: { + color: '#888' + }, + x: 3, + y: -4 + }, + crosshair: false + }, + yAxis: { + gridLineWidth: 0, + startOnTick: false, + endOnTick: false, + minPadding: 0.1, + maxPadding: 0.1, + labels: { + enabled: false + }, + crosshair: false, + title: { + text: null + }, + tickWidth: 0 + } + }, + scrollbar: { + //enabled: true + height: isTouchDevice ? 20 : 14, + barBackgroundColor: '#bfc8d1', + barBorderRadius: 0, + barBorderWidth: 1, + barBorderColor: '#bfc8d1', + buttonArrowColor: '#666', + buttonBackgroundColor: '#ebe7e8', + buttonBorderColor: '#bbb', + buttonBorderRadius: 0, + buttonBorderWidth: 1, + minWidth: 6, + rifleColor: '#666', + trackBackgroundColor: '#eeeeee', + trackBorderColor: '#eeeeee', + trackBorderWidth: 1, + // trackBorderRadius: 0 + liveRedraw: hasSVG && !isTouchDevice + } +}); + +/** + * The Scroller class + * @param {Object} chart + */ +function Scroller(chart) { + var chartOptions = chart.options, + navigatorOptions = chartOptions.navigator, + navigatorEnabled = navigatorOptions.enabled, + scrollbarOptions = chartOptions.scrollbar, + scrollbarEnabled = scrollbarOptions.enabled, + height = navigatorEnabled ? navigatorOptions.height : 0, + scrollbarHeight = scrollbarEnabled ? scrollbarOptions.height : 0; + + + this.handles = []; + this.scrollbarButtons = []; + this.elementsToDestroy = []; // Array containing the elements to destroy when Scroller is destroyed + + this.chart = chart; + this.setBaseSeries(); + + this.height = height; + this.scrollbarHeight = scrollbarHeight; + this.scrollbarEnabled = scrollbarEnabled; + this.navigatorEnabled = navigatorEnabled; + this.navigatorOptions = navigatorOptions; + this.scrollbarOptions = scrollbarOptions; + this.outlineHeight = height + scrollbarHeight; + + // Run scroller + this.init(); +} + +Scroller.prototype = { + /** + * Draw one of the handles on the side of the zoomed range in the navigator + * @param {Number} x The x center for the handle + * @param {Number} index 0 for left and 1 for right + */ + drawHandle: function (x, index) { + var scroller = this, + chart = scroller.chart, + renderer = chart.renderer, + elementsToDestroy = scroller.elementsToDestroy, + handles = scroller.handles, + handlesOptions = scroller.navigatorOptions.handles, + attr = { + fill: handlesOptions.backgroundColor, + stroke: handlesOptions.borderColor, + 'stroke-width': 1 + }, + tempElem; + + // create the elements + if (!scroller.rendered) { + // the group + handles[index] = renderer.g('navigator-handle-' + ['left', 'right'][index]) + .css({ cursor: 'e-resize' }) + .attr({ zIndex: 4 - index }) // zIndex = 3 for right handle, 4 for left + .add(); + + // the rectangle + tempElem = renderer.rect(-4.5, 0, 9, 16, 0, 1) + .attr(attr) + .add(handles[index]); + elementsToDestroy.push(tempElem); + + // the rifles + tempElem = renderer.path([ + 'M', + -1.5, 4, + 'L', + -1.5, 12, + 'M', + 0.5, 4, + 'L', + 0.5, 12 + ]).attr(attr) + .add(handles[index]); + elementsToDestroy.push(tempElem); + } + + // Place it + handles[index][chart.isResizing ? 'animate' : 'attr']({ + translateX: scroller.scrollerLeft + scroller.scrollbarHeight + parseInt(x, 10), + translateY: scroller.top + scroller.height / 2 - 8 + }); + }, + + /** + * Draw the scrollbar buttons with arrows + * @param {Number} index 0 is left, 1 is right + */ + drawScrollbarButton: function (index) { + var scroller = this, + chart = scroller.chart, + renderer = chart.renderer, + elementsToDestroy = scroller.elementsToDestroy, + scrollbarButtons = scroller.scrollbarButtons, + scrollbarHeight = scroller.scrollbarHeight, + scrollbarOptions = scroller.scrollbarOptions, + tempElem; + + if (!scroller.rendered) { + scrollbarButtons[index] = renderer.g().add(scroller.scrollbarGroup); + + tempElem = renderer.rect( + -0.5, + -0.5, + scrollbarHeight + 1, // +1 to compensate for crispifying in rect method + scrollbarHeight + 1, + scrollbarOptions.buttonBorderRadius, + scrollbarOptions.buttonBorderWidth + ).attr({ + stroke: scrollbarOptions.buttonBorderColor, + 'stroke-width': scrollbarOptions.buttonBorderWidth, + fill: scrollbarOptions.buttonBackgroundColor + }).add(scrollbarButtons[index]); + elementsToDestroy.push(tempElem); + + tempElem = renderer.path([ + 'M', + scrollbarHeight / 2 + (index ? -1 : 1), scrollbarHeight / 2 - 3, + 'L', + scrollbarHeight / 2 + (index ? -1 : 1), scrollbarHeight / 2 + 3, + scrollbarHeight / 2 + (index ? 2 : -2), scrollbarHeight / 2 + ]).attr({ + fill: scrollbarOptions.buttonArrowColor + }).add(scrollbarButtons[index]); + elementsToDestroy.push(tempElem); + } + + // adjust the right side button to the varying length of the scroll track + if (index) { + scrollbarButtons[index].attr({ + translateX: scroller.scrollerWidth - scrollbarHeight + }); + } + }, + + /** + * Render the navigator and scroll bar + * @param {Number} min X axis value minimum + * @param {Number} max X axis value maximum + * @param {Number} pxMin Pixel value minimum + * @param {Number} pxMax Pixel value maximum + */ + render: function (min, max, pxMin, pxMax) { + var scroller = this, + chart = scroller.chart, + renderer = chart.renderer, + navigatorLeft, + navigatorWidth, + scrollerLeft, + scrollerWidth, + scrollbarGroup = scroller.scrollbarGroup, + navigatorGroup = scroller.navigatorGroup, + scrollbar = scroller.scrollbar, + xAxis = scroller.xAxis, + scrollbarTrack = scroller.scrollbarTrack, + scrollbarHeight = scroller.scrollbarHeight, + scrollbarEnabled = scroller.scrollbarEnabled, + navigatorOptions = scroller.navigatorOptions, + scrollbarOptions = scroller.scrollbarOptions, + scrollbarMinWidth = scrollbarOptions.minWidth, + height = scroller.height, + top = scroller.top, + navigatorEnabled = scroller.navigatorEnabled, + outlineWidth = navigatorOptions.outlineWidth, + halfOutline = outlineWidth / 2, + zoomedMin, + zoomedMax, + range, + scrX, + scrWidth, + scrollbarPad = 0, + outlineHeight = scroller.outlineHeight, + barBorderRadius = scrollbarOptions.barBorderRadius, + strokeWidth, + scrollbarStrokeWidth = scrollbarOptions.barBorderWidth, + centerBarX, + outlineTop = top + halfOutline, + verb, + unionExtremes; + + // don't render the navigator until we have data (#486) + if (isNaN(min)) { + return; + } + + scroller.navigatorLeft = navigatorLeft = pick( + xAxis.left, + chart.plotLeft + scrollbarHeight // in case of scrollbar only, without navigator + ); + scroller.navigatorWidth = navigatorWidth = pick(xAxis.len, chart.plotWidth - 2 * scrollbarHeight); + scroller.scrollerLeft = scrollerLeft = navigatorLeft - scrollbarHeight; + scroller.scrollerWidth = scrollerWidth = scrollerWidth = navigatorWidth + 2 * scrollbarHeight; + + // Set the scroller x axis extremes to reflect the total. The navigator extremes + // should always be the extremes of the union of all series in the chart as + // well as the navigator series. + if (xAxis.getExtremes) { + unionExtremes = scroller.getUnionExtremes(true); + + if (unionExtremes && (unionExtremes.dataMin !== xAxis.min || unionExtremes.dataMax !== xAxis.max)) { + xAxis.setExtremes(unionExtremes.dataMin, unionExtremes.dataMax, true, false); + } + } + + // Get the pixel position of the handles + pxMin = pick(pxMin, xAxis.translate(min)); + pxMax = pick(pxMax, xAxis.translate(max)); + if (isNaN(pxMin) || mathAbs(pxMin) === Infinity) { // Verify (#1851, #2238) + pxMin = 0; + pxMax = scrollerWidth; + } + + // Are we below the minRange? (#2618) + if (xAxis.translate(pxMax, true) - xAxis.translate(pxMin, true) < chart.xAxis[0].minRange) { + return; + } + + + // handles are allowed to cross, but never exceed the plot area + scroller.zoomedMax = mathMin(mathMax(pxMin, pxMax), navigatorWidth); + scroller.zoomedMin = + mathMax(scroller.fixedWidth ? scroller.zoomedMax - scroller.fixedWidth : mathMin(pxMin, pxMax), 0); + scroller.range = scroller.zoomedMax - scroller.zoomedMin; + zoomedMax = mathRound(scroller.zoomedMax); + zoomedMin = mathRound(scroller.zoomedMin); + range = zoomedMax - zoomedMin; + + + + // on first render, create all elements + if (!scroller.rendered) { + + if (navigatorEnabled) { + + // draw the navigator group + scroller.navigatorGroup = navigatorGroup = renderer.g('navigator') + .attr({ + zIndex: 3 + }) + .add(); + + scroller.leftShade = renderer.rect() + .attr({ + fill: navigatorOptions.maskFill + }).add(navigatorGroup); + if (!navigatorOptions.maskInside) { + scroller.rightShade = renderer.rect() + .attr({ + fill: navigatorOptions.maskFill + }).add(navigatorGroup); + } + + + scroller.outline = renderer.path() + .attr({ + 'stroke-width': outlineWidth, + stroke: navigatorOptions.outlineColor + }) + .add(navigatorGroup); + } + + if (scrollbarEnabled) { + + // draw the scrollbar group + scroller.scrollbarGroup = scrollbarGroup = renderer.g('scrollbar').add(); + + // the scrollbar track + strokeWidth = scrollbarOptions.trackBorderWidth; + scroller.scrollbarTrack = scrollbarTrack = renderer.rect().attr({ + x: 0, + y: -strokeWidth % 2 / 2, + fill: scrollbarOptions.trackBackgroundColor, + stroke: scrollbarOptions.trackBorderColor, + 'stroke-width': strokeWidth, + r: scrollbarOptions.trackBorderRadius || 0, + height: scrollbarHeight + }).add(scrollbarGroup); + + // the scrollbar itself + scroller.scrollbar = scrollbar = renderer.rect() + .attr({ + y: -scrollbarStrokeWidth % 2 / 2, + height: scrollbarHeight, + fill: scrollbarOptions.barBackgroundColor, + stroke: scrollbarOptions.barBorderColor, + 'stroke-width': scrollbarStrokeWidth, + r: barBorderRadius + }) + .add(scrollbarGroup); + + scroller.scrollbarRifles = renderer.path() + .attr({ + stroke: scrollbarOptions.rifleColor, + 'stroke-width': 1 + }) + .add(scrollbarGroup); + } + } + + // place elements + verb = chart.isResizing ? 'animate' : 'attr'; + + if (navigatorEnabled) { + scroller.leftShade[verb](navigatorOptions.maskInside ? { + x: navigatorLeft + zoomedMin, + y: top, + width: zoomedMax - zoomedMin, + height: height + } : { + x: navigatorLeft, + y: top, + width: zoomedMin, + height: height + }); + if (scroller.rightShade) { + scroller.rightShade[verb]({ + x: navigatorLeft + zoomedMax, + y: top, + width: navigatorWidth - zoomedMax, + height: height + }); + } + + scroller.outline[verb]({ d: [ + M, + scrollerLeft, outlineTop, // left + L, + navigatorLeft + zoomedMin + halfOutline, outlineTop, // upper left of zoomed range + navigatorLeft + zoomedMin + halfOutline, outlineTop + outlineHeight, // lower left of z.r. + L, + navigatorLeft + zoomedMax - halfOutline, outlineTop + outlineHeight, // lower right of z.r. + L, + navigatorLeft + zoomedMax - halfOutline, outlineTop, // upper right of z.r. + scrollerLeft + scrollerWidth, outlineTop // right + ].concat(navigatorOptions.maskInside ? [ + M, + navigatorLeft + zoomedMin + halfOutline, outlineTop, // upper left of zoomed range + L, + navigatorLeft + zoomedMax - halfOutline, outlineTop // upper right of z.r. + ] : [])}); + // draw handles + scroller.drawHandle(zoomedMin + halfOutline, 0); + scroller.drawHandle(zoomedMax + halfOutline, 1); + } + + // draw the scrollbar + if (scrollbarEnabled && scrollbarGroup) { + + // draw the buttons + scroller.drawScrollbarButton(0); + scroller.drawScrollbarButton(1); + + scrollbarGroup[verb]({ + translateX: scrollerLeft, + translateY: mathRound(outlineTop + height) + }); + + scrollbarTrack[verb]({ + width: scrollerWidth + }); + + // prevent the scrollbar from drawing to small (#1246) + scrX = scrollbarHeight + zoomedMin; + scrWidth = range - scrollbarStrokeWidth; + if (scrWidth < scrollbarMinWidth) { + scrollbarPad = (scrollbarMinWidth - scrWidth) / 2; + scrWidth = scrollbarMinWidth; + scrX -= scrollbarPad; + } + scroller.scrollbarPad = scrollbarPad; + scrollbar[verb]({ + x: mathFloor(scrX) + (scrollbarStrokeWidth % 2 / 2), + width: scrWidth + }); + + centerBarX = scrollbarHeight + zoomedMin + range / 2 - 0.5; + + scroller.scrollbarRifles + .attr({ + visibility: range > 12 ? VISIBLE : HIDDEN + })[verb]({ + d: [ + M, + centerBarX - 3, scrollbarHeight / 4, + L, + centerBarX - 3, 2 * scrollbarHeight / 3, + M, + centerBarX, scrollbarHeight / 4, + L, + centerBarX, 2 * scrollbarHeight / 3, + M, + centerBarX + 3, scrollbarHeight / 4, + L, + centerBarX + 3, 2 * scrollbarHeight / 3 + ] + }); + } + + scroller.scrollbarPad = scrollbarPad; + scroller.rendered = true; + }, + + /** + * Set up the mouse and touch events for the navigator and scrollbar + */ + addEvents: function () { + var container = this.chart.container, + mouseDownHandler = this.mouseDownHandler, + mouseMoveHandler = this.mouseMoveHandler, + mouseUpHandler = this.mouseUpHandler, + _events; + + // Mouse events + _events = [ + [container, 'mousedown', mouseDownHandler], + [container, 'mousemove', mouseMoveHandler], + [document, 'mouseup', mouseUpHandler] + ]; + + // Touch events + if (hasTouch) { + _events.push( + [container, 'touchstart', mouseDownHandler], + [container, 'touchmove', mouseMoveHandler], + [document, 'touchend', mouseUpHandler] + ); + } + + // Add them all + each(_events, function (args) { + addEvent.apply(null, args); + }); + this._events = _events; + }, + + /** + * Removes the event handlers attached previously with addEvents. + */ + removeEvents: function () { + + each(this._events, function (args) { + removeEvent.apply(null, args); + }); + this._events = UNDEFINED; + if (this.navigatorEnabled && this.baseSeries) { + removeEvent(this.baseSeries, 'updatedData', this.updatedDataHandler); + } + }, + + /** + * Initiate the Scroller object + */ + init: function () { + var scroller = this, + chart = scroller.chart, + xAxis, + yAxis, + scrollbarHeight = scroller.scrollbarHeight, + navigatorOptions = scroller.navigatorOptions, + height = scroller.height, + top = scroller.top, + dragOffset, + hasDragged, + bodyStyle = document.body.style, + defaultBodyCursor, + baseSeries = scroller.baseSeries; + + /** + * Event handler for the mouse down event. + */ + scroller.mouseDownHandler = function (e) { + e = chart.pointer.normalize(e); + + var zoomedMin = scroller.zoomedMin, + zoomedMax = scroller.zoomedMax, + top = scroller.top, + scrollbarHeight = scroller.scrollbarHeight, + scrollerLeft = scroller.scrollerLeft, + scrollerWidth = scroller.scrollerWidth, + navigatorLeft = scroller.navigatorLeft, + navigatorWidth = scroller.navigatorWidth, + scrollbarPad = scroller.scrollbarPad, + range = scroller.range, + chartX = e.chartX, + chartY = e.chartY, + baseXAxis = chart.xAxis[0], + fixedMax, + ext, + handleSensitivity = isTouchDevice ? 10 : 7, + left, + isOnNavigator; + + if (chartY > top && chartY < top + height + scrollbarHeight) { // we're vertically inside the navigator + isOnNavigator = !scroller.scrollbarEnabled || chartY < top + height; + + // grab the left handle + if (isOnNavigator && math.abs(chartX - zoomedMin - navigatorLeft) < handleSensitivity) { + scroller.grabbedLeft = true; + scroller.otherHandlePos = zoomedMax; + scroller.fixedExtreme = baseXAxis.max; + chart.fixedRange = null; + + // grab the right handle + } else if (isOnNavigator && math.abs(chartX - zoomedMax - navigatorLeft) < handleSensitivity) { + scroller.grabbedRight = true; + scroller.otherHandlePos = zoomedMin; + scroller.fixedExtreme = baseXAxis.min; + chart.fixedRange = null; + + // grab the zoomed range + } else if (chartX > navigatorLeft + zoomedMin - scrollbarPad && chartX < navigatorLeft + zoomedMax + scrollbarPad) { + scroller.grabbedCenter = chartX; + scroller.fixedWidth = range; + + // In SVG browsers, change the cursor. IE6 & 7 produce an error on changing the cursor, + // and IE8 isn't able to show it while dragging anyway. + if (chart.renderer.isSVG) { + defaultBodyCursor = bodyStyle.cursor; + bodyStyle.cursor = 'ew-resize'; + } + + dragOffset = chartX - zoomedMin; + + + // shift the range by clicking on shaded areas, scrollbar track or scrollbar buttons + } else if (chartX > scrollerLeft && chartX < scrollerLeft + scrollerWidth) { + + // Center around the clicked point + if (isOnNavigator) { + left = chartX - navigatorLeft - range / 2; + + // Click on scrollbar + } else { + + // Click left scrollbar button + if (chartX < navigatorLeft) { + left = zoomedMin - range * 0.2; + + // Click right scrollbar button + } else if (chartX > scrollerLeft + scrollerWidth - scrollbarHeight) { + left = zoomedMin + range * 0.2; + + // Click on scrollbar track, shift the scrollbar by one range + } else { + left = chartX < navigatorLeft + zoomedMin ? // on the left + zoomedMin - range : + zoomedMax; + } + } + if (left < 0) { + left = 0; + } else if (left + range >= navigatorWidth) { + left = navigatorWidth - range; + fixedMax = xAxis.dataMax; // #2293 + } + if (left !== zoomedMin) { // it has actually moved + scroller.fixedWidth = range; // #1370 + + ext = xAxis.toFixedRange(left, left + range, null, fixedMax); + baseXAxis.setExtremes( + ext.min, + ext.max, + true, + false, + { trigger: 'navigator' } + ); + } + } + + } + }; + + /** + * Event handler for the mouse move event. + */ + scroller.mouseMoveHandler = function (e) { + var scrollbarHeight = scroller.scrollbarHeight, + navigatorLeft = scroller.navigatorLeft, + navigatorWidth = scroller.navigatorWidth, + scrollerLeft = scroller.scrollerLeft, + scrollerWidth = scroller.scrollerWidth, + range = scroller.range, + chartX; + + // In iOS, a mousemove event with e.pageX === 0 is fired when holding the finger + // down in the center of the scrollbar. This should be ignored. + if (e.pageX !== 0) { + + e = chart.pointer.normalize(e); + chartX = e.chartX; + + // validation for handle dragging + if (chartX < navigatorLeft) { + chartX = navigatorLeft; + } else if (chartX > scrollerLeft + scrollerWidth - scrollbarHeight) { + chartX = scrollerLeft + scrollerWidth - scrollbarHeight; + } + + // drag left handle + if (scroller.grabbedLeft) { + hasDragged = true; + scroller.render(0, 0, chartX - navigatorLeft, scroller.otherHandlePos); + + // drag right handle + } else if (scroller.grabbedRight) { + hasDragged = true; + scroller.render(0, 0, scroller.otherHandlePos, chartX - navigatorLeft); + + // drag scrollbar or open area in navigator + } else if (scroller.grabbedCenter) { + + hasDragged = true; + if (chartX < dragOffset) { // outside left + chartX = dragOffset; + } else if (chartX > navigatorWidth + dragOffset - range) { // outside right + chartX = navigatorWidth + dragOffset - range; + } + + scroller.render(0, 0, chartX - dragOffset, chartX - dragOffset + range); + + } + if (hasDragged && scroller.scrollbarOptions.liveRedraw) { + setTimeout(function () { + scroller.mouseUpHandler(e); + }, 0); + } + } + }; + + /** + * Event handler for the mouse up event. + */ + scroller.mouseUpHandler = function (e) { + var ext, + fixedMin, + fixedMax; + + if (hasDragged) { + // When dragging one handle, make sure the other one doesn't change + if (scroller.zoomedMin === scroller.otherHandlePos) { + fixedMin = scroller.fixedExtreme; + } else if (scroller.zoomedMax === scroller.otherHandlePos) { + fixedMax = scroller.fixedExtreme; + } + + ext = xAxis.toFixedRange(scroller.zoomedMin, scroller.zoomedMax, fixedMin, fixedMax); + chart.xAxis[0].setExtremes( + ext.min, + ext.max, + true, + false, + { + trigger: 'navigator', + triggerOp: 'navigator-drag', + DOMEvent: e // #1838 + } + ); + } + + if (e.type !== 'mousemove') { + scroller.grabbedLeft = scroller.grabbedRight = scroller.grabbedCenter = scroller.fixedWidth = + scroller.fixedExtreme = scroller.otherHandlePos = hasDragged = dragOffset = null; + bodyStyle.cursor = defaultBodyCursor || ''; + } + + }; + + + + var xAxisIndex = chart.xAxis.length, + yAxisIndex = chart.yAxis.length; + + // make room below the chart + chart.extraBottomMargin = scroller.outlineHeight + navigatorOptions.margin; + + if (scroller.navigatorEnabled) { + // an x axis is required for scrollbar also + scroller.xAxis = xAxis = new Axis(chart, merge({ + ordinal: baseSeries && baseSeries.xAxis.options.ordinal // inherit base xAxis' ordinal option + }, navigatorOptions.xAxis, { + id: 'navigator-x-axis', + isX: true, + type: 'datetime', + index: xAxisIndex, + height: height, + offset: 0, + offsetLeft: scrollbarHeight, + offsetRight: -scrollbarHeight, + keepOrdinalPadding: true, // #2436 + startOnTick: false, + endOnTick: false, + minPadding: 0, + maxPadding: 0, + zoomEnabled: false + })); + + scroller.yAxis = yAxis = new Axis(chart, merge(navigatorOptions.yAxis, { + id: 'navigator-y-axis', + alignTicks: false, + height: height, + offset: 0, + index: yAxisIndex, + zoomEnabled: false + })); + + // If we have a base series, initialize the navigator series + if (baseSeries || navigatorOptions.series.data) { + scroller.addBaseSeries(); + + // If not, set up an event to listen for added series + } else if (chart.series.length === 0) { + + wrap(chart, 'redraw', function (proceed, animation) { + // We've got one, now add it as base and reset chart.redraw + if (chart.series.length > 0 && !scroller.series) { + scroller.setBaseSeries(); + chart.redraw = proceed; // reset + } + proceed.call(chart, animation); + }); + } + + + // in case of scrollbar only, fake an x axis to get translation + } else { + scroller.xAxis = xAxis = { + translate: function (value, reverse) { + var axis = chart.xAxis[0], + ext = axis.getExtremes(), + scrollTrackWidth = chart.plotWidth - 2 * scrollbarHeight, + min = numExt('min', axis.options.min, ext.dataMin), + valueRange = numExt('max', axis.options.max, ext.dataMax) - min; + + return reverse ? + // from pixel to value + (value * valueRange / scrollTrackWidth) + min : + // from value to pixel + scrollTrackWidth * (value - min) / valueRange; + }, + toFixedRange: Axis.prototype.toFixedRange + }; + } + + + /** + * For stock charts, extend the Chart.getMargins method so that we can set the final top position + * of the navigator once the height of the chart, including the legend, is determined. #367. + */ + wrap(chart, 'getMargins', function (proceed) { + + var legend = this.legend, + legendOptions = legend.options; + + proceed.call(this); + + // Compute the top position + scroller.top = top = scroller.navigatorOptions.top || + this.chartHeight - scroller.height - scroller.scrollbarHeight - this.spacing[2] - + (legendOptions.verticalAlign === 'bottom' && legendOptions.enabled && !legendOptions.floating ? + legend.legendHeight + pick(legendOptions.margin, 10) : 0); + + if (xAxis && yAxis) { // false if navigator is disabled (#904) + + xAxis.options.top = yAxis.options.top = top; + + xAxis.setAxisSize(); + yAxis.setAxisSize(); + } + }); + + + scroller.addEvents(); + }, + + /** + * Get the union data extremes of the chart - the outer data extremes of the base + * X axis and the navigator axis. + */ + getUnionExtremes: function (returnFalseOnNoBaseSeries) { + var baseAxis = this.chart.xAxis[0], + navAxis = this.xAxis, + navAxisOptions = navAxis.options, + baseAxisOptions = baseAxis.options; + + if (!returnFalseOnNoBaseSeries || baseAxis.dataMin !== null) { + return { + dataMin: numExt( + 'min', + navAxisOptions && navAxisOptions.min, + baseAxisOptions.min, + baseAxis.dataMin, + navAxis.dataMin + ), + dataMax: numExt( + 'max', + navAxisOptions && navAxisOptions.max, + baseAxisOptions.max, + baseAxis.dataMax, + navAxis.dataMax + ) + }; + } + }, + + /** + * Set the base series. With a bit of modification we should be able to make + * this an API method to be called from the outside + */ + setBaseSeries: function (baseSeriesOption) { + var chart = this.chart; + + baseSeriesOption = baseSeriesOption || chart.options.navigator.baseSeries; + + // If we're resetting, remove the existing series + if (this.series) { + this.series.remove(); + } + + // Set the new base series + this.baseSeries = chart.series[baseSeriesOption] || + (typeof baseSeriesOption === 'string' && chart.get(baseSeriesOption)) || + chart.series[0]; + + // When run after render, this.xAxis already exists + if (this.xAxis) { + this.addBaseSeries(); + } + }, + + addBaseSeries: function () { + var baseSeries = this.baseSeries, + baseOptions = baseSeries ? baseSeries.options : {}, + baseData = baseOptions.data, + mergedNavSeriesOptions, + navigatorSeriesOptions = this.navigatorOptions.series, + navigatorData; + + // remove it to prevent merging one by one + navigatorData = navigatorSeriesOptions.data; + this.hasNavigatorData = !!navigatorData; + + // Merge the series options + mergedNavSeriesOptions = merge(baseOptions, navigatorSeriesOptions, { + enableMouseTracking: false, + group: 'nav', // for columns + padXAxis: false, + xAxis: 'navigator-x-axis', + yAxis: 'navigator-y-axis', + name: 'Navigator', + showInLegend: false, + isInternal: true, + visible: true + }); + + // set the data back + mergedNavSeriesOptions.data = navigatorData || baseData; + + // add the series + this.series = this.chart.initSeries(mergedNavSeriesOptions); + + // Respond to updated data in the base series. + // Abort if lazy-loading data from the server. + if (baseSeries && this.navigatorOptions.adaptToUpdatedData !== false) { + addEvent(baseSeries, 'updatedData', this.updatedDataHandler); + // Survive Series.update() + baseSeries.userOptions.events = extend(baseSeries.userOptions.event, { updatedData: this.updatedDataHandler }); + + } + }, + + updatedDataHandler: function () { + var scroller = this.chart.scroller, + baseSeries = scroller.baseSeries, + baseXAxis = baseSeries.xAxis, + baseExtremes = baseXAxis.getExtremes(), + baseMin = baseExtremes.min, + baseMax = baseExtremes.max, + baseDataMin = baseExtremes.dataMin, + baseDataMax = baseExtremes.dataMax, + range = baseMax - baseMin, + stickToMin, + stickToMax, + newMax, + newMin, + doRedraw, + navigatorSeries = scroller.series, + navXData = navigatorSeries.xData, + hasSetExtremes = !!baseXAxis.setExtremes; + + // detect whether to move the range + stickToMax = baseMax >= navXData[navXData.length - 1] - (this.closestPointRange || 0); // #570 + stickToMin = baseMin <= baseDataMin; + + // set the navigator series data to the new data of the base series + if (!scroller.hasNavigatorData) { + navigatorSeries.options.pointStart = baseSeries.xData[0]; + navigatorSeries.setData(baseSeries.options.data, false); + doRedraw = true; + } + + // if the zoomed range is already at the min, move it to the right as new data + // comes in + if (stickToMin) { + newMin = baseDataMin; + newMax = newMin + range; + } + + // if the zoomed range is already at the max, move it to the right as new data + // comes in + if (stickToMax) { + newMax = baseDataMax; + if (!stickToMin) { // if stickToMin is true, the new min value is set above + newMin = mathMax(newMax - range, navigatorSeries.xData[0]); + } + } + + // update the extremes + if (hasSetExtremes && (stickToMin || stickToMax)) { + if (!isNaN(newMin)) { + baseXAxis.setExtremes(newMin, newMax, true, false, { trigger: 'updatedData' }); + } + + // if it is not at any edge, just move the scroller window to reflect the new series data + } else { + if (doRedraw) { + this.chart.redraw(false); + } + + scroller.render( + mathMax(baseMin, baseDataMin), + mathMin(baseMax, baseDataMax) + ); + } + }, + + /** + * Destroys allocated elements. + */ + destroy: function () { + var scroller = this; + + // Disconnect events added in addEvents + scroller.removeEvents(); + + // Destroy properties + each([scroller.xAxis, scroller.yAxis, scroller.leftShade, scroller.rightShade, scroller.outline, scroller.scrollbarTrack, scroller.scrollbarRifles, scroller.scrollbarGroup, scroller.scrollbar], function (prop) { + if (prop && prop.destroy) { + prop.destroy(); + } + }); + scroller.xAxis = scroller.yAxis = scroller.leftShade = scroller.rightShade = scroller.outline = scroller.scrollbarTrack = scroller.scrollbarRifles = scroller.scrollbarGroup = scroller.scrollbar = null; + + // Destroy elements in collection + each([scroller.scrollbarButtons, scroller.handles, scroller.elementsToDestroy], function (coll) { + destroyObjectProperties(coll); + }); + } +}; + +Highcharts.Scroller = Scroller; + + +/** + * For Stock charts, override selection zooming with some special features because + * X axis zooming is already allowed by the Navigator and Range selector. + */ +wrap(Axis.prototype, 'zoom', function (proceed, newMin, newMax) { + var chart = this.chart, + chartOptions = chart.options, + zoomType = chartOptions.chart.zoomType, + previousZoom, + navigator = chartOptions.navigator, + rangeSelector = chartOptions.rangeSelector, + ret; + + if (this.isXAxis && ((navigator && navigator.enabled) || + (rangeSelector && rangeSelector.enabled))) { + + // For x only zooming, fool the chart.zoom method not to create the zoom button + // because the property already exists + if (zoomType === 'x') { + chart.resetZoomButton = 'blocked'; + + // For y only zooming, ignore the X axis completely + } else if (zoomType === 'y') { + ret = false; + + // For xy zooming, record the state of the zoom before zoom selection, then when + // the reset button is pressed, revert to this state + } else if (zoomType === 'xy') { + previousZoom = this.previousZoom; + if (defined(newMin)) { + this.previousZoom = [this.min, this.max]; + } else if (previousZoom) { + newMin = previousZoom[0]; + newMax = previousZoom[1]; + delete this.previousZoom; + } + } + + } + return ret !== UNDEFINED ? ret : proceed.call(this, newMin, newMax); +}); + +// Initialize scroller for stock charts +wrap(Chart.prototype, 'init', function (proceed, options, callback) { + + addEvent(this, 'beforeRender', function () { + var options = this.options; + if (options.navigator.enabled || options.scrollbar.enabled) { + this.scroller = new Scroller(this); + } + }); + + proceed.call(this, options, callback); + +}); + +// Pick up badly formatted point options to addPoint +wrap(Series.prototype, 'addPoint', function (proceed, options, redraw, shift, animation) { + var turboThreshold = this.options.turboThreshold; + if (turboThreshold && this.xData.length > turboThreshold && isObject(options) && !isArray(options) && this.chart.scroller) { + error(20, true); + } + proceed.call(this, options, redraw, shift, animation); +}); + +/* **************************************************************************** + * End Scroller code * + *****************************************************************************/ +/* **************************************************************************** + * Start Range Selector code * + *****************************************************************************/ +extend(defaultOptions, { + rangeSelector: { + // allButtonsEnabled: false, + // enabled: true, + // buttons: {Object} + // buttonSpacing: 0, + buttonTheme: { + width: 28, + height: 18, + fill: '#f7f7f7', + padding: 2, + r: 0, + 'stroke-width': 0, + style: { + color: '#444', + cursor: 'pointer', + fontWeight: 'normal' + }, + zIndex: 7, // #484, #852 + states: { + hover: { + fill: '#e7e7e7' + }, + select: { + fill: '#e7f0f9', + style: { + color: 'black', + fontWeight: 'bold' + } + } + } + }, + inputPosition: { + align: 'right' + }, + // inputDateFormat: '%b %e, %Y', + // inputEditDateFormat: '%Y-%m-%d', + // inputEnabled: true, + // inputStyle: {}, + labelStyle: { + color: '#666' + } + // selected: undefined + } +}); +defaultOptions.lang = merge(defaultOptions.lang, { + rangeSelectorZoom: '缩放', + rangeSelectorFrom: '从', + rangeSelectorTo: '到' +}); + +/** + * The object constructor for the range selector + * @param {Object} chart + */ +function RangeSelector(chart) { + + // Run RangeSelector + this.init(chart); +} + +RangeSelector.prototype = { + /** + * The method to run when one of the buttons in the range selectors is clicked + * @param {Number} i The index of the button + * @param {Object} rangeOptions + * @param {Boolean} redraw + */ + clickButton: function (i, redraw) { + var rangeSelector = this, + selected = rangeSelector.selected, + chart = rangeSelector.chart, + buttons = rangeSelector.buttons, + rangeOptions = rangeSelector.buttonOptions[i], + baseAxis = chart.xAxis[0], + unionExtremes = (chart.scroller && chart.scroller.getUnionExtremes()) || baseAxis || {}, + dataMin = unionExtremes.dataMin, + dataMax = unionExtremes.dataMax, + newMin, + newMax = baseAxis && mathRound(mathMin(baseAxis.max, pick(dataMax, baseAxis.max))), // #1568 + now, + date = new Date(newMax), + type = rangeOptions.type, + count = rangeOptions.count, + baseXAxisOptions, + range = rangeOptions._range, + rangeMin, + year, + timeName; + + if (dataMin === null || dataMax === null || // chart has no data, base series is removed + i === rangeSelector.selected) { // same button is clicked twice + return; + } + + if (type === 'month' || type === 'year') { + timeName = { month: 'Month', year: 'FullYear'}[type]; + date['set' + timeName](date['get' + timeName]() - count); + + newMin = date.getTime(); + dataMin = pick(dataMin, Number.MIN_VALUE); + if (isNaN(newMin) || newMin < dataMin) { + newMin = dataMin; + newMax = mathMin(newMin + range, dataMax); + } else { + range = newMax - newMin; + } + + // Fixed times like minutes, hours, days + } else if (range) { + newMin = mathMax(newMax - range, dataMin); + newMax = mathMin(newMin + range, dataMax); + + } else if (type === 'ytd') { + + // On user clicks on the buttons, or a delayed action running from the beforeRender + // event (below), the baseAxis is defined. + if (baseAxis) { + + // When "ytd" is the pre-selected button for the initial view, its calculation + // is delayed and rerun in the beforeRender event (below). When the series + // are initialized, but before the chart is rendered, we have access to the xData + // array (#942). + if (dataMax === UNDEFINED) { + dataMin = Number.MAX_VALUE; + dataMax = Number.MIN_VALUE; + each(chart.series, function (series) { + var xData = series.xData; // reassign it to the last item + dataMin = mathMin(xData[0], dataMin); + dataMax = mathMax(xData[xData.length - 1], dataMax); + }); + redraw = false; + } + now = new Date(dataMax); + year = now.getFullYear(); + newMin = rangeMin = mathMax(dataMin || 0, Date.UTC(year, 0, 1)); + now = now.getTime(); + newMax = mathMin(dataMax || now, now); + + // "ytd" is pre-selected. We don't yet have access to processed point and extremes data + // (things like pointStart and pointInterval are missing), so we delay the process (#942) + } else { + addEvent(chart, 'beforeRender', function () { + rangeSelector.clickButton(i); + }); + return; + } + } else if (type === 'all' && baseAxis) { + newMin = dataMin; + newMax = dataMax; + } + + // Deselect previous button + if (buttons[selected]) { + buttons[selected].setState(0); + } + // Select this button + if (buttons[i]) { + buttons[i].setState(2); + } + + chart.fixedRange = range; + + // update the chart + if (!baseAxis) { // axis not yet instanciated + baseXAxisOptions = chart.options.xAxis; + baseXAxisOptions[0] = merge( + baseXAxisOptions[0], + { + range: range, + min: rangeMin + } + ); + rangeSelector.setSelected(i); + } else { // existing axis object; after render time + baseAxis.setExtremes( + newMin, + newMax, + pick(redraw, 1), + 0, + { + trigger: 'rangeSelectorButton', + rangeSelectorButton: rangeOptions + } + ); + rangeSelector.setSelected(i); + } + }, + + /** + * Set the selected option. This method only sets the internal flag, it doesn't + * update the buttons or the actual zoomed range. + */ + setSelected: function (selected) { + this.selected = this.options.selected = selected; + }, + + /** + * The default buttons for pre-selecting time frames + */ + defaultButtons: [{ + type: 'month', + count: 1, + text: '1个月' + }, { + type: 'month', + count: 3, + text: '3个月' + }, { + type: 'month', + count: 6, + text: '6个月' + }, { + type: 'ytd', + text: 'YTD' + }, { + type: 'year', + count: 1, + text: '1年' + }, { + type: 'all', + text: '全部' + }], + + /** + * Initialize the range selector + */ + init: function (chart) { + + var rangeSelector = this, + options = chart.options.rangeSelector, + buttonOptions = options.buttons || [].concat(rangeSelector.defaultButtons), + selectedOption = options.selected, + blurInputs = rangeSelector.blurInputs = function () { + var minInput = rangeSelector.minInput, + maxInput = rangeSelector.maxInput; + if (minInput) { + minInput.blur(); + } + if (maxInput) { + maxInput.blur(); + } + }; + + rangeSelector.chart = chart; + rangeSelector.options = options; + rangeSelector.buttons = []; + + chart.extraTopMargin = 35; + rangeSelector.buttonOptions = buttonOptions; + + addEvent(chart.container, 'mousedown', blurInputs); + addEvent(chart, 'resize', blurInputs); + + // Extend the buttonOptions with actual range + each(buttonOptions, rangeSelector.computeButtonRange); + + // zoomed range based on a pre-selected button index + if (selectedOption !== UNDEFINED && buttonOptions[selectedOption]) { + this.clickButton(selectedOption, false); + } + + // normalize the pressed button whenever a new range is selected + addEvent(chart, 'load', function () { + addEvent(chart.xAxis[0], 'afterSetExtremes', function () { + rangeSelector.updateButtonStates(true); + }); + }); + }, + + /** + * Dynamically update the range selector buttons after a new range has been set + */ + updateButtonStates: function (updating) { + var rangeSelector = this, + chart = this.chart, + baseAxis = chart.xAxis[0], + unionExtremes = (chart.scroller && chart.scroller.getUnionExtremes()) || baseAxis, + dataMin = unionExtremes.dataMin, + dataMax = unionExtremes.dataMax, + selected = rangeSelector.selected, + allButtonsEnabled = rangeSelector.options.allButtonsEnabled, + buttons = rangeSelector.buttons; + + if (updating && chart.fixedRange !== mathRound(baseAxis.max - baseAxis.min)) { + if (buttons[selected]) { + buttons[selected].setState(0); + } + rangeSelector.setSelected(null); + } + + each(rangeSelector.buttonOptions, function (rangeOptions, i) { + var range = rangeOptions._range, + // Disable buttons where the range exceeds what is allowed in the current view + isTooGreatRange = range > dataMax - dataMin, + // Disable buttons where the range is smaller than the minimum range + isTooSmallRange = range < baseAxis.minRange, + // Disable the All button if we're already showing all + isAllButAlreadyShowingAll = rangeOptions.type === 'all' && baseAxis.max - baseAxis.min >= dataMax - dataMin && + buttons[i].state !== 2, + // Disable the YTD button if the complete range is within the same year + isYTDButNotAvailable = rangeOptions.type === 'ytd' && dateFormat('%Y', dataMin) === dateFormat('%Y', dataMax); + + // The new zoom area happens to match the range for a button - mark it selected. + // This happens when scrolling across an ordinal gap. It can be seen in the intraday + // demos when selecting 1h and scroll across the night gap. + if (range === mathRound(baseAxis.max - baseAxis.min) && i !== selected) { + rangeSelector.setSelected(i); + buttons[i].setState(2); + + } else if (!allButtonsEnabled && (isTooGreatRange || isTooSmallRange || isAllButAlreadyShowingAll || isYTDButNotAvailable)) { + buttons[i].setState(3); + + } else if (buttons[i].state === 3) { + buttons[i].setState(0); + } + }); + }, + + /** + * Compute and cache the range for an individual button + */ + computeButtonRange: function (rangeOptions) { + var type = rangeOptions.type, + count = rangeOptions.count || 1, + + // these time intervals have a fixed number of milliseconds, as opposed + // to month, ytd and year + fixedTimes = { + millisecond: 1, + second: 1000, + minute: 60 * 1000, + hour: 3600 * 1000, + day: 24 * 3600 * 1000, + week: 7 * 24 * 3600 * 1000 + }; + + // Store the range on the button object + if (fixedTimes[type]) { + rangeOptions._range = fixedTimes[type] * count; + } else if (type === 'month' || type === 'year') { + rangeOptions._range = { month: 30, year: 365 }[type] * 24 * 36e5 * count; + } + }, + + /** + * Set the internal and displayed value of a HTML input for the dates + * @param {String} name + * @param {Number} time + */ + setInputValue: function (name, time) { + var options = this.chart.options.rangeSelector; + + if (defined(time)) { + this[name + 'Input'].HCTime = time; + } + this[name + 'Input'].value = dateFormat(options.inputEditDateFormat || '%Y-%m-%d', this[name + 'Input'].HCTime); + }, + + /** + * Draw either the 'from' or the 'to' HTML input box of the range selector + * @param {Object} name + */ + drawInput: function (name) { + var rangeSelector = this, + chart = rangeSelector.chart, + chartStyle = chart.renderer.style, + renderer = chart.renderer, + options = chart.options.rangeSelector, + lang = defaultOptions.lang, + div = rangeSelector.div, + isMin = name === 'min', + input, + label, + dateBox, + inputGroup = this.inputGroup; + + + + + // Create the HTML input element. This is rendered as 1x1 pixel then set to the right size + // when focused. + this[name + 'Input'] = input = createElement('input', { + name: name, + className: PREFIX + 'range-selector', + type: 'text' + }, extend({ + position: ABSOLUTE, + border: 0, + width: '1px', // Chrome needs a pixel to see it + height: '1px', + padding: 0, + textAlign: 'center', + fontSize: chartStyle.fontSize, + fontFamily: chartStyle.fontFamily, + top: chart.plotTop + PX // prevent jump on focus in Firefox + }, options.inputStyle), div); + + // Blow up the input box + input.onfocus = function () { + css(this, { + left: (inputGroup.translateX + dateBox.x) + PX, + top: inputGroup.translateY + PX, + width: (dateBox.width - 2) + PX, + height: (dateBox.height - 2) + PX, + border: '2px solid silver' + }); + }; + // Hide away the input box + input.onblur = function () { + css(this, { + border: 0, + width: '1px', + height: '1px' + }); + rangeSelector.setInputValue(name); + }; + + // handle changes in the input boxes + input.onchange = function () { + var inputValue = input.value, + value = (options.inputDateParser || Date.parse)(inputValue), + xAxis = chart.xAxis[0], + dataMin = xAxis.dataMin, + dataMax = xAxis.dataMax; + + // If the value isn't parsed directly to a value by the browser's Date.parse method, + // like YYYY-MM-DD in IE, try parsing it a different way + if (isNaN(value)) { + value = inputValue.split('-'); + value = Date.UTC(pInt(value[0]), pInt(value[1]) - 1, pInt(value[2])); + } + + if (!isNaN(value)) { + + // Correct for timezone offset (#433) + if (!defaultOptions.global.useUTC) { + value = value + new Date().getTimezoneOffset() * 60 * 1000; + } + + // Validate the extremes. If it goes beyound the data min or max, use the + // actual data extreme (#2438). + if (isMin) { + if (value > rangeSelector.maxInput.HCTime) { + value = UNDEFINED; + } else if (value < dataMin) { + value = dataMin; + } + } else { + if (value < rangeSelector.minInput.HCTime) { + value = UNDEFINED; + } else if (value > dataMax) { + value = dataMax; + } + } + + // Set the extremes + if (value !== UNDEFINED) { + chart.xAxis[0].setExtremes( + isMin ? value : xAxis.min, + isMin ? xAxis.max : value, + UNDEFINED, + UNDEFINED, + { trigger: 'rangeSelectorInput' } + ); + } + } + }; + }, + + /** + * Render the range selector including the buttons and the inputs. The first time render + * is called, the elements are created and positioned. On subsequent calls, they are + * moved and updated. + * @param {Number} min X axis minimum + * @param {Number} max X axis maximum + */ + render: function (min, max) { + + var rangeSelector = this, + chart = rangeSelector.chart, + renderer = chart.renderer, + container = chart.container, + chartOptions = chart.options, + navButtonOptions = chartOptions.exporting && chartOptions.navigation && chartOptions.navigation.buttonOptions, + options = chartOptions.rangeSelector, + buttons = rangeSelector.buttons, + lang = defaultOptions.lang, + div = rangeSelector.div, + inputGroup = rangeSelector.inputGroup, + buttonTheme = options.buttonTheme, + inputEnabled = options.inputEnabled !== false, + states = buttonTheme && buttonTheme.states, + plotLeft = chart.plotLeft, + yAlign, + buttonLeft; + + // create the elements + if (!rangeSelector.rendered) { + rangeSelector.zoomText = renderer.text(lang.rangeSelectorZoom, plotLeft, chart.plotTop - 20) + .css(options.labelStyle) + .add(); + + // button starting position + buttonLeft = plotLeft + rangeSelector.zoomText.getBBox().width + 5; + + each(rangeSelector.buttonOptions, function (rangeOptions, i) { + buttons[i] = renderer.button( + rangeOptions.text, + buttonLeft, + chart.plotTop - 35, + function () { + rangeSelector.clickButton(i); + rangeSelector.isActive = true; + }, + buttonTheme, + states && states.hover, + states && states.select + ) + .css({ + textAlign: 'center' + }) + .add(); + + // increase button position for the next button + buttonLeft += buttons[i].width + pick(options.buttonSpacing, 5); + + if (rangeSelector.selected === i) { + buttons[i].setState(2); + } + }); + + rangeSelector.updateButtonStates(); + + // first create a wrapper outside the container in order to make + // the inputs work and make export correct + if (inputEnabled) { + rangeSelector.div = div = createElement('div', null, { + position: 'relative', + height: 0, + zIndex: 1 // above container + }); + + container.parentNode.insertBefore(div, container); + + // Create the group to keep the inputs + rangeSelector.inputGroup = inputGroup = renderer.g('input-group') + .add(); + inputGroup.offset = 0; + + rangeSelector.drawInput('min'); + rangeSelector.drawInput('max'); + } + } + + if (inputEnabled) { + + // Update the alignment to the updated spacing box + yAlign = chart.plotTop - 45; + inputGroup.align(extend({ + y: yAlign, + width: inputGroup.offset, + // detect collision with the exporting buttons + x: navButtonOptions && (yAlign < (navButtonOptions.y || 0) + navButtonOptions.height - chart.spacing[0]) ? + -40 : 0 + }, options.inputPosition), true, chart.spacingBox); + + // Set or reset the input values + rangeSelector.setInputValue('min', min); + rangeSelector.setInputValue('max', max); + } + + rangeSelector.rendered = true; + }, + + /** + * Destroys allocated elements. + */ + destroy: function () { + var minInput = this.minInput, + maxInput = this.maxInput, + chart = this.chart, + blurInputs = this.blurInputs, + key; + + removeEvent(chart.container, 'mousedown', blurInputs); + removeEvent(chart, 'resize', blurInputs); + + // Destroy elements in collections + destroyObjectProperties(this.buttons); + + // Clear input element events + if (minInput) { + minInput.onfocus = minInput.onblur = minInput.onchange = null; + } + if (maxInput) { + maxInput.onfocus = maxInput.onblur = maxInput.onchange = null; + } + + // Destroy HTML and SVG elements + for (key in this) { + if (this[key] && key !== 'chart') { + if (this[key].destroy) { // SVGElement + this[key].destroy(); + } else if (this[key].nodeType) { // HTML element + discardElement(this[key]); + } + } + this[key] = null; + } + } +}; + +/** + * Add logic to normalize the zoomed range in order to preserve the pressed state of range selector buttons + */ +Axis.prototype.toFixedRange = function (pxMin, pxMax, fixedMin, fixedMax) { + var fixedRange = this.chart && this.chart.fixedRange, + newMin = pick(fixedMin, this.translate(pxMin, true)), + newMax = pick(fixedMax, this.translate(pxMax, true)), + changeRatio = fixedRange && (newMax - newMin) / fixedRange; + + // If the difference between the fixed range and the actual requested range is + // too great, the user is dragging across an ordinal gap, and we need to release + // the range selector button. + if (changeRatio > 0.7 && changeRatio < 1.3) { + if (fixedMax) { + newMin = newMax - fixedRange; + } else { + newMax = newMin + fixedRange; + } + } + + return { + min: newMin, + max: newMax + }; +}; + +// Initialize scroller for stock charts +wrap(Chart.prototype, 'init', function (proceed, options, callback) { + + addEvent(this, 'init', function () { + if (this.options.rangeSelector.enabled) { + this.rangeSelector = new RangeSelector(this); + } + }); + + proceed.call(this, options, callback); + +}); + + +Highcharts.RangeSelector = RangeSelector; + +/* **************************************************************************** + * End Range Selector code * + *****************************************************************************/ + + + +Chart.prototype.callbacks.push(function (chart) { + var extremes, + scroller = chart.scroller, + rangeSelector = chart.rangeSelector; + + function renderScroller() { + extremes = chart.xAxis[0].getExtremes(); + scroller.render(extremes.min, extremes.max); + } + + function renderRangeSelector() { + extremes = chart.xAxis[0].getExtremes(); + if (!isNaN(extremes.min)) { + rangeSelector.render(extremes.min, extremes.max); + } + } + + function afterSetExtremesHandlerScroller(e) { + if (e.triggerOp !== 'navigator-drag') { + scroller.render(e.min, e.max); + } + } + + function afterSetExtremesHandlerRangeSelector(e) { + rangeSelector.render(e.min, e.max); + } + + function destroyEvents() { + if (scroller) { + removeEvent(chart.xAxis[0], 'afterSetExtremes', afterSetExtremesHandlerScroller); + } + if (rangeSelector) { + removeEvent(chart, 'resize', renderRangeSelector); + removeEvent(chart.xAxis[0], 'afterSetExtremes', afterSetExtremesHandlerRangeSelector); + } + } + + // initiate the scroller + if (scroller) { + // redraw the scroller on setExtremes + addEvent(chart.xAxis[0], 'afterSetExtremes', afterSetExtremesHandlerScroller); + + // redraw the scroller on chart resize or box resize + wrap(chart, 'drawChartBox', function (proceed) { + var isDirtyBox = this.isDirtyBox; + proceed.call(this); + if (isDirtyBox) { + renderScroller(); + } + }); + + // do it now + renderScroller(); + } + if (rangeSelector) { + // redraw the scroller on setExtremes + addEvent(chart.xAxis[0], 'afterSetExtremes', afterSetExtremesHandlerRangeSelector); + + // redraw the scroller chart resize + addEvent(chart, 'resize', renderRangeSelector); + + // do it now + renderRangeSelector(); + } + + // Remove resize/afterSetExtremes at chart destroy + addEvent(chart, 'destroy', destroyEvents); +}); +/** + * A wrapper for Chart with all the default values for a Stock chart + */ +Highcharts.StockChart = function (options, callback) { + var seriesOptions = options.series, // to increase performance, don't merge the data + opposite, + + // Always disable startOnTick:true on the main axis when the navigator is enabled (#1090) + navigatorEnabled = pick(options.navigator && options.navigator.enabled, true), + disableStartOnTick = navigatorEnabled ? { + startOnTick: false, + endOnTick: false + } : null, + + lineOptions = { + + marker: { + enabled: false, + radius: 2 + }, + // gapSize: 0, + states: { + hover: { + lineWidth: 2 + } + } + }, + columnOptions = { + shadow: false, + borderWidth: 0 + }; + + // apply X axis options to both single and multi y axes + options.xAxis = map(splat(options.xAxis || {}), function (xAxisOptions) { + return merge({ // defaults + minPadding: 0, + maxPadding: 0, + ordinal: true, + title: { + text: null + }, + labels: { + overflow: 'justify' + }, + showLastLabel: true + }, xAxisOptions, // user options + { // forced options + type: 'datetime', + categories: null + }, + disableStartOnTick + ); + }); + + // apply Y axis options to both single and multi y axes + options.yAxis = map(splat(options.yAxis || {}), function (yAxisOptions) { + opposite = pick(yAxisOptions.opposite, true); + return merge({ // defaults + labels: { + y: -2 + }, + opposite: opposite, + showLastLabel: false, + title: { + text: null + } + }, yAxisOptions // user options + ); + }); + + options.series = null; + + options = merge({ + chart: { + panning: true, + pinchType: 'x' + }, + navigator: { + enabled: true + }, + scrollbar: { + enabled: true + }, + rangeSelector: { + enabled: true + }, + title: { + text: null, + style: { + fontSize: '16px' + } + }, + tooltip: { + shared: true, + crosshairs: true + }, + legend: { + enabled: false + }, + + plotOptions: { + line: lineOptions, + spline: lineOptions, + area: lineOptions, + areaspline: lineOptions, + arearange: lineOptions, + areasplinerange: lineOptions, + column: columnOptions, + columnrange: columnOptions, + candlestick: columnOptions, + ohlc: columnOptions + } + + }, + options, // user's options + + { // forced options + _stock: true, // internal flag + chart: { + inverted: false + } + }); + + options.series = seriesOptions; + + + return new Chart(options, callback); +}; + +// Implement the pinchType option +wrap(Pointer.prototype, 'init', function (proceed, chart, options) { + + var pinchType = options.chart.pinchType || ''; + + proceed.call(this, chart, options); + + // Pinch status + this.pinchX = this.pinchHor = pinchType.indexOf('x') !== -1; + this.pinchY = this.pinchVert = pinchType.indexOf('y') !== -1; + this.hasZoom = this.hasZoom || this.pinchHor || this.pinchVert; +}); + +// Override the automatic label alignment so that the first Y axis' labels +// are drawn on top of the grid line, and subsequent axes are drawn outside +wrap(Axis.prototype, 'autoLabelAlign', function (proceed) { + if (this.chart.options._stock && this.coll === 'yAxis') { + if (inArray(this, this.chart.yAxis) === 0) { + if (this.options.labels.x === 15) { // default + this.options.labels.x = 0; + } + return 'right'; + } + } + return proceed.call(this, [].slice.call(arguments, 1)); +}); + +// Override getPlotLinePath to allow for multipane charts +Axis.prototype.getPlotLinePath = function (value, lineWidth, old, force, translatedValue) { + var axis = this, + series = (this.isLinked && !this.series ? this.linkedParent.series : this.series), + chart = axis.chart, + renderer = chart.renderer, + axisLeft = axis.left, + axisTop = axis.top, + x1, + y1, + x2, + y2, + result = [], + axes, + axes2, + uniqueAxes; + + // Get the related axes based on series + axes = (axis.isXAxis ? + (defined(axis.options.yAxis) ? + [chart.yAxis[axis.options.yAxis]] : + map(series, function (S) { return S.yAxis; }) + ) : + (defined(axis.options.xAxis) ? + [chart.xAxis[axis.options.xAxis]] : + map(series, function (S) { return S.xAxis; }) + ) + ); + + + // Get the related axes based options.*Axis setting #2810 + axes2 = (axis.isXAxis ? chart.yAxis : chart.xAxis); + each(axes2, function (A) { + if (defined(A.options.id) ? A.options.id.indexOf('navigator') === -1 : true) { + var a = (A.isXAxis ? 'yAxis' : 'xAxis'), + rax = (defined(A.options[a]) ? chart[a][A.options[a]] : chart[a][0]); + + if (axis === rax) { + axes.push(A); + } + } + }); + + + // Remove duplicates in the axes array. If there are no axes in the axes array, + // we are adding an axis without data, so we need to populate this with grid + // lines (#2796). + uniqueAxes = axes.length ? [] : [axis]; + each(axes, function (axis2) { + if (inArray(axis2, uniqueAxes) === -1) { + uniqueAxes.push(axis2); + } + }); + + translatedValue = pick(translatedValue, axis.translate(value, null, null, old)); + + if (!isNaN(translatedValue)) { + if (axis.horiz) { + each(uniqueAxes, function (axis2) { + y1 = axis2.top; + y2 = y1 + axis2.len; + x1 = x2 = mathRound(translatedValue + axis.transB); + + if ((x1 >= axisLeft && x1 <= axisLeft + axis.width) || force) { + result.push('M', x1, y1, 'L', x2, y2); + } + }); + } else { + each(uniqueAxes, function (axis2) { + x1 = axis2.left; + x2 = x1 + axis2.width; + y1 = y2 = mathRound(axisTop + axis.height - translatedValue); + + if ((y1 >= axisTop && y1 <= axisTop + axis.height) || force) { + result.push('M', x1, y1, 'L', x2, y2); + } + }); + } + } + if (result.length > 0) { + return renderer.crispPolyLine(result, lineWidth || 1); + } +}; + +// Override getPlotBandPath to allow for multipane charts +Axis.prototype.getPlotBandPath = function (from, to) { + var toPath = this.getPlotLinePath(to), + path = this.getPlotLinePath(from), + result = [], + i; + + if (path && toPath) { + // Go over each subpath + for (i = 0; i < path.length; i += 6) { + result.push('M', path[i + 1], path[i + 2], 'L', path[i + 4], path[i + 5], toPath[i + 4], toPath[i + 5], toPath[i + 1], toPath[i + 2]); + } + } else { // outside the axis area + result = null; + } + + return result; +}; + +// Function to crisp a line with multiple segments +SVGRenderer.prototype.crispPolyLine = function (points, width) { + // points format: [M, 0, 0, L, 100, 0] + // normalize to a crisp line + var i; + for (i = 0; i < points.length; i = i + 6) { + if (points[i + 1] === points[i + 4]) { + // Substract due to #1129. Now bottom and left axis gridlines behave the same. + points[i + 1] = points[i + 4] = mathRound(points[i + 1]) - (width % 2 / 2); + } + if (points[i + 2] === points[i + 5]) { + points[i + 2] = points[i + 5] = mathRound(points[i + 2]) + (width % 2 / 2); + } + } + return points; +}; +if (Renderer === Highcharts.VMLRenderer) { + VMLRenderer.prototype.crispPolyLine = SVGRenderer.prototype.crispPolyLine; +} + + +// Wrapper to hide the label +wrap(Axis.prototype, 'hideCrosshair', function (proceed, i) { + proceed.call(this, i); + + if (!defined(this.crossLabelArray)) { return; } + + if (defined(i)) { + if (this.crossLabelArray[i]) { this.crossLabelArray[i].hide(); } + } else { + each(this.crossLabelArray, function (crosslabel) { + crosslabel.hide(); + }); + } +}); + +// Wrapper to draw the label +wrap(Axis.prototype, 'drawCrosshair', function (proceed, e, point) { + // Draw the crosshair + proceed.call(this, e, point); + + // Check if the label has to be drawn + if (!defined(this.crosshair.label) || !this.crosshair.label.enabled || !defined(point)) { + return; + } + + var chart = this.chart, + options = this.options.crosshair.label, // the label's options + axis = this.isXAxis ? 'x' : 'y', // axis name + horiz = this.horiz, // axis orientation + opposite = this.opposite, // axis position + left = this.left, // left position + top = this.top, // top position + crossLabel = this.crossLabel, // reference to the svgElement + posx, + posy, + crossBox, + formatOption = options.format, + formatFormat = '', + limit; + + // If the label does not exist yet, create it. + if (!crossLabel) { + crossLabel = this.crossLabel = chart.renderer.label() + .attr({ + align: options.align || (horiz ? 'center' : opposite ? (this.labelAlign === 'right' ? 'right' : 'left') : (this.labelAlign === 'left' ? 'left' : 'center')), + zIndex: 12, + height: horiz ? 16 : UNDEFINED, + fill: options.backgroundColor || (this.series[0] && this.series[0].color) || 'gray', + padding: pick(options.padding, 2), + stroke: options.borderColor || null, + 'stroke-width': options.borderWidth || 0 + }) + .css(extend({ + color: 'white', + fontWeight: 'normal', + fontSize: '11px', + textAlign: 'center' + }, options.style)) + .add(); + } + + if (horiz) { + posx = point.plotX + left; + posy = top + (opposite ? 0 : this.height); + } else { + posx = opposite ? this.width + left : 0; + posy = point.plotY + top; + } + + // if the crosshair goes out of view (too high or too low, hide it and hide the label) + if (posy < top || posy > top + this.height) { + this.hideCrosshair(); + return; + } + + // TODO: Dynamic date formats like in Series.tooltipHeaderFormat. + if (!formatOption && !options.formatter) { + if (this.isDatetimeAxis) { + formatFormat = '%b %d, %Y'; + } + formatOption = '{value' + (formatFormat ? ':' + formatFormat : '') + '}'; + } + + // show the label + crossLabel.attr({ + text: formatOption ? format(formatOption, {value: point[axis]}) : options.formatter.call(this, point[axis]), + x: posx, + y: posy, + visibility: VISIBLE + }); + crossBox = crossLabel.getBBox(); + + // now it is placed we can correct its position + if (horiz) { + if (((this.options.tickPosition === 'inside') && !opposite) || + ((this.options.tickPosition !== 'inside') && opposite)) { + posy = crossLabel.y - crossBox.height; + } + } else { + posy = crossLabel.y - (crossBox.height / 2); + } + + // check the edges + if (horiz) { + limit = { + left: left - crossBox.x, + right: left + this.width - crossBox.x + }; + } else { + limit = { + left: this.labelAlign === 'left' ? left : 0, + right: this.labelAlign === 'right' ? left + this.width : chart.chartWidth + }; + } + + // left edge + if (crossLabel.translateX < limit.left) { + posx += limit.left - crossLabel.translateX; + } + // right edge + if (crossLabel.translateX + crossBox.width >= limit.right) { + posx -= crossLabel.translateX + crossBox.width - limit.right; + } + + // show the crosslabel + crossLabel.attr({x: posx, y: posy, visibility: VISIBLE}); +}); + +/* **************************************************************************** + * Start value compare logic * + *****************************************************************************/ + +var seriesInit = seriesProto.init, + seriesProcessData = seriesProto.processData, + pointTooltipFormatter = Point.prototype.tooltipFormatter; + +/** + * Extend series.init by adding a method to modify the y value used for plotting + * on the y axis. This method is called both from the axis when finding dataMin + * and dataMax, and from the series.translate method. + */ +seriesProto.init = function () { + + // Call base method + seriesInit.apply(this, arguments); + + // Set comparison mode + this.setCompare(this.options.compare); +}; + +/** + * The setCompare method can be called also from the outside after render time + */ +seriesProto.setCompare = function (compare) { + + // Set or unset the modifyValue method + this.modifyValue = (compare === 'value' || compare === 'percent') ? function (value, point) { + var compareValue = this.compareValue; + + if (value !== UNDEFINED) { // #2601 + + // get the modified value + value = compare === 'value' ? + value - compareValue : // compare value + value = 100 * (value / compareValue) - 100; // compare percent + + // record for tooltip etc. + if (point) { + point.change = value; + } + + } + + return value; + } : null; + + // Mark dirty + if (this.chart.hasRendered) { + this.isDirty = true; + } + +}; + +/** + * Extend series.processData by finding the first y value in the plot area, + * used for comparing the following values + */ +seriesProto.processData = function () { + var series = this, + i = 0, + processedXData, + processedYData, + length; + + // call base method + seriesProcessData.apply(this, arguments); + + if (series.xAxis && series.processedYData) { // not pies + + // local variables + processedXData = series.processedXData; + processedYData = series.processedYData; + length = processedYData.length; + + // find the first value for comparison + for (; i < length; i++) { + if (typeof processedYData[i] === NUMBER && processedXData[i] >= series.xAxis.min) { + series.compareValue = processedYData[i]; + break; + } + } + } +}; + +/** + * Modify series extremes + */ +wrap(seriesProto, 'getExtremes', function (proceed) { + proceed.apply(this, [].slice.call(arguments, 1)); + + if (this.modifyValue) { + this.dataMax = this.modifyValue(this.dataMax); + this.dataMin = this.modifyValue(this.dataMin); + } +}); + +/** + * Add a utility method, setCompare, to the Y axis + */ +Axis.prototype.setCompare = function (compare, redraw) { + if (!this.isXAxis) { + each(this.series, function (series) { + series.setCompare(compare); + }); + if (pick(redraw, true)) { + this.chart.redraw(); + } + } +}; + +/** + * Extend the tooltip formatter by adding support for the point.change variable + * as well as the changeDecimals option + */ +Point.prototype.tooltipFormatter = function (pointFormat) { + var point = this; + + pointFormat = pointFormat.replace( + '{point.change}', + (point.change > 0 ? '+' : '') + numberFormat(point.change, pick(point.series.tooltipOptions.changeDecimals, 2)) + ); + + return pointTooltipFormatter.apply(this, [pointFormat]); +}; + +/* **************************************************************************** + * End value compare logic * + *****************************************************************************/ + + +/** + * Extend the Series prototype to create a separate series clip box. This is related + * to using multiple panes, and a future pane logic should incorporate this feature. + */ +wrap(Series.prototype, 'render', function (proceed) { + // Only do this on stock charts (#2939), and only if the series type handles clipping + // in the animate method (#2975). + if (this.chart.options._stock) { + + // First render, initial clip box + if (!this.clipBox && this.animate && this.animate.toString().indexOf('sharedClip') !== -1) { + this.clipBox = merge(this.chart.clipBox); + this.clipBox.width = this.xAxis.len; + this.clipBox.height = this.yAxis.len; + + // On redrawing, resizing etc, update the clip rectangle + } else if (this.chart[this.sharedClipKey]) { + this.chart[this.sharedClipKey].attr({ + width: this.xAxis.len, + height: this.yAxis.len + }); + } + } + proceed.call(this); +}); + +// global variables +extend(Highcharts, { + + // Constructors + Axis: Axis, + Chart: Chart, + Color: Color, + Point: Point, + Tick: Tick, + Renderer: Renderer, + Series: Series, + SVGElement: SVGElement, + SVGRenderer: SVGRenderer, + + // Various + arrayMin: arrayMin, + arrayMax: arrayMax, + charts: charts, + dateFormat: dateFormat, + format: format, + pathAnim: pathAnim, + getOptions: getOptions, + hasBidiBug: hasBidiBug, + isTouchDevice: isTouchDevice, + numberFormat: numberFormat, + seriesTypes: seriesTypes, + setOptions: setOptions, + addEvent: addEvent, + removeEvent: removeEvent, + createElement: createElement, + discardElement: discardElement, + css: css, + each: each, + extend: extend, + map: map, + merge: merge, + pick: pick, + splat: splat, + extendClass: extendClass, + pInt: pInt, + wrap: wrap, + svg: hasSVG, + canvas: useCanVG, + vml: !hasSVG && !useCanVG, + product: PRODUCT, + version: VERSION +}); + +}());