diff --git a/PRD.md b/PRD.md deleted file mode 100644 index 96ec51b..0000000 --- a/PRD.md +++ /dev/null @@ -1,462 +0,0 @@ -# MujicaUI 产品需求文档 - -版本:1.1 -日期:2026-10-05 -交付范围:500 个组件、两套主题、三种密度、原生 Gallery、文档与测试。001—100 见本文第 4 节;101—500 见 `docs/prd/ext-1.md`、`ext-2.md`、`ext-3.md`。 - -## 1. 产品定义 - -MujicaUI 是基于 MyGo 原生 UI 的 Go 桌面组件库。采用古典宫廷与哥特风格,覆盖 Windows、macOS、Linux。面向开发桌面工具、设置界面和数据应用的 Go 开发者。 - -MyGo 原生 UI 使用 Go 构建并自绘界面,不依赖 HTML、JavaScript 或 WebView。MujicaUI 复用该路线,不开发网页组件库。[^native] - -首版交付本文及扩展文件规定的全部功能。已有组件换肤、已有能力组合和新增组件合计 500 个,不是在 MyGo 之外再增加 500 个。尺寸、颜色、状态、子部件和模式不单独计数。 - -不包含浏览器端适配、新渲染引擎、新布局引擎、独立状态管理框架、业务后端和上传服务。图表与行情组件以 MyGo Painter 绘制,不建通用图表引擎;媒体组件只提供播放与采集界面,解码与设备访问由调用方完成;文档类组件只提供显示与轻量编辑,不建富文本编辑引擎。 - -## 2. MyGo 复用规则 - -| 情况 | 实现要求 | -| --- | --- | -| 只需调整颜色、字体、尺寸 | 封装现有组件,使用主题与公开样式接口。 | -| 需要改变外形或内部布局 | 使用已公开的 Base,重做外观,保留输入、焦点和无障碍行为。 | -| 需要组合交互 | 组合 MyGo 与 MujicaUI 组件;仅补充缺失的状态和行为。 | -| 系统窗口操作、文件选择 | 调用 MyGo 桌面 API;系统控制的外观保持系统样式。 | -| 公开接口不足 | 提交上游扩展;合入后固定依赖版本。不访问上游 internal,不另建渲染或输入实现。 | - -已核验的 Base 包括 Button、Checkbox、Switch、Toggle、Radio、Slider、Tabs、Segmented、Collapsible、Select、Combobox、Popover、Dialog、TextInput 和 TextArea 对应的 Base。不得假设存在未公开的 TableBase、TreeBase 等接口。[^bases] - -应用内 Menubar、DropdownMenu、ContextMenu 使用 MujicaUI 自绘样式。系统菜单、系统文件对话框和系统窗口按钮不承诺换肤;不得把系统菜单直接显示为已完成的哥特样式菜单。 - -MyGo 的主题可调整颜色、字体、间距与圆角,也可读取高对比度、文本缩放和减少动画设置。MujicaUI 扩展设计变量,不另建 CSS 或样式解释器。[^theme] - -## 3. 视觉规范 - -### 3.1 风格与装饰 - -视觉参考为用户提供的宫廷场景图片:象牙白墙面、黑红服装、金色细节与衬线标题。只提取配色、线条和字形,不使用人物、海报、Logo 或照片作为组件素材。 - -页面以象牙白或炭黑为底。酒红表示主要操作与选中状态。旧金用于分隔线、角饰和面板标题,不用于正文。成功、警告和错误使用独立语义颜色。 - -按钮、输入框和数据行不加花纹。Heading、Divider 可使用单个菱形或对称细线。Card、Dialog 的双线边框与角饰通过 `Decorated` 开启,默认关闭。装饰不占焦点、不遮挡内容、不缩小点击区域。 - -不使用背景纹理、仿皮革、仿金属、发光、玻璃效果和装饰性渐变。颜色选择器中的色谱不受此限制。 - -### 3.2 颜色 - -以下为首版默认设计值,不是从参考图精确取色的结果。 - -| 变量 | 浅色主题 | 深色主题 | 用途 | -| --- | --- | --- | --- | -| Background | `#F2EBDD` | `#191618` | 窗口背景 | -| Surface | `#FBF7EF` | `#242024` | 卡片与弹层 | -| SurfaceHover | `#E8DFD0` | `#342C32` | 悬停背景 | -| Text | `#211C1D` | `#F2EBDD` | 正文与标题 | -| TextMuted | `#6A5B55` | `#BCAEAA` | 说明与占位文字 | -| Border | `#D2C3AF` | `#51424A` | 非交互分隔线 | -| ControlBorder | `#877662` | `#968278` | 输入框等交互边界 | -| Accent | `#7D2034` | `#9C3C53` | 主按钮填充 | -| AccentHover | `#651729` | `#AA425C` | 主按钮悬停 | -| AccentPressed | `#511020` | `#813047` | 主按钮按下 | -| OnAccent | `#FFF8EE` | `#FFF8EE` | 主按钮文字与图标 | -| AccentText | `#7D2034` | `#E4A0AE` | 链接与强调文字 | -| Selection | `#EAD7D9` | `#492A36` | 选中项背景 | -| Focus | `#7D2034` | `#E4A0AE` | 焦点环 | -| Ornament | `#AD9164` | `#BDA172` | 非语义装饰 | -| Success | `#326247` | `#94C6A4` | 成功文字与图标 | -| Warning | `#805714` | `#DFC080` | 警告文字与图标 | -| Danger | `#A12635` | `#F0A1AA` | 错误文字与图标 | -| Info | `#355E80` | `#9DBDDA` | 信息文字与图标 | - -语义颜色默认用于文字、图标和边界,不直接拿浅色主题值填满深色主题控件。危险按钮的底色与前景色单独定义并检查对比度。焦点环与控件之间保留背景色间隔。 - -### 3.3 字体、尺寸与密度 - -标题使用衬线字体,中文标题使用宋体类字形。正文、表单、表格使用系统无衬线字体。代码与快捷键使用系统等宽字体。装饰字形不用于控件标签。 - -标题默认 28、22、18 DIP;正文 14 DIP;说明文字 12 DIP;正文行高为字号的 1.5 倍。字体允许由应用注册与覆盖;缺失字体使用系统回退,不显示空白或方框替代正常文字。MyGo 提供字体注册与字体回退接口。[^text] - -| 密度 | 单行控件最小高度 | 数据行最小高度 | 基础间距单位 | -| --- | --- | --- | --- | -| Compact | 28 DIP | 28 DIP | 3 DIP | -| Default | 36 DIP | 36 DIP | 4 DIP | -| Comfortable | 44 DIP | 44 DIP | 5 DIP | - -默认间距采用 4、8、12、16、24、32、48 DIP。输入框与按钮圆角 2 DIP;卡片 4 DIP;弹层 6 DIP。边框 1 DIP;焦点环 2 DIP,外留 2 DIP 间隔。图标采用 16、20、24 DIP,独立操作的命中区域不小于 28 × 28 DIP。 - -高度是下限,不是固定值。文本放大、换行或字体回退时允许增长。切换密度不缩小字号,也不改变组件语义。细线须按设备像素对齐。 - -### 3.4 组件外观与主题行为 - -Button 使用酒红填充、象牙白文字与小圆角。TextInput 使用实色背景与细边框。Checkbox 保留方框和勾选、半选含义;Radio 保留单选圆形。Slider 可使用菱形滑块,命中区域仍为矩形。Tabs 与 Sidebar 使用背景、边线和文字共同标记当前项。 - -Card 与 Dialog 的装饰只出现在边缘和标题区。DataTable 表头、行分隔和选中行不添加纹样。Button、TextInput、Checkbox、Tabs、Card、Dialog 作为视觉回归基准。 - -主题提供 `System`、`Light`、`Dark`,默认跟随系统。每个窗口可独立设置主题与密度。切换主题不得重置表单值、展开状态、选择、焦点或滚动位置。 - -样式覆盖顺序为局部参数、窗口主题、库默认值。系统高对比度、文本缩放与减少动画要求仍须生效。主色默认保留酒红,不自动替换为系统强调色;高对比度模式以可读性为先。 - -状态过渡默认 120 毫秒,弹层过渡 160 毫秒。不使用弹跳或装饰性循环动画。减少动画模式直接切换状态;加载标记保留静态说明。 - -## 4. 组件清单 - -以下每行是一个公开组件及其首版最低功能。全部适用组件同时遵守第 5 节的交互要求。 - -### 4.1 布局与桌面 · 10 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 001 | AppShell | 标题栏、侧栏、主内容、状态栏插槽;侧栏折叠;内容区独立滚动。 | -| 002 | TitleBar | 应用标题与工具插槽;窗口拖动;为系统窗口按钮留出空间。 | -| 003 | StatusBar | 左右分区;状态文字、进度与操作;宽度不足时折叠次要内容。 | -| 004 | Container | 内边距、最大宽度、居中、填充;内容裁剪可配置。 | -| 005 | Stack | 横向或纵向排列;间距、对齐、换行;隐藏项不占间距。 | -| 006 | Grid | 行列尺寸、间距、跨行跨列;固定尺寸与剩余空间分配。 | -| 007 | ScrollArea | 横纵滚动;滚动条;滚动到指定位置;保留滚动位置。 | -| 008 | SplitPane | 横纵分栏;拖动分隔条;最小尺寸;折叠;键盘调整。 | -| 009 | AspectRatio | 保持指定宽高比;在父容器约束下缩放;不拉伸子内容。 | -| 010 | Divider | 横纵分隔;文字插槽;可选菱形装饰;不获取焦点。 | - -### 4.2 基础展示 · 10 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 011 | Text | 换行、省略、选择复制;可编辑模式支持确认、取消和外部保存结果。 | -| 012 | Heading | 三级标题;衬线字形;可选副标题和分隔线;支持文本缩放。 | -| 013 | Icon | SVG 图标;尺寸与语义颜色;图标名称;加载失败回退。 | -| 014 | Image | 位图与 SVG;包含或裁切;占位、加载、错误;替代说明。 | -| 015 | Avatar | 图片、姓名首字母与默认图标回退;尺寸可配置。 | -| 016 | AvatarGroup | 头像排列;最大显示数量;剩余数量;展开完整名单。 | -| 017 | Badge | 文字或计数;上限显示;位置可配置;朗读完整数值。 | -| 018 | Tag | 标签文字、图标;可关闭;关闭动作与标签点击分离。 | -| 019 | Link | 外部地址或应用内动作;键盘激活;文本下划线;打开失败反馈。 | -| 020 | Kbd | 单键与组合键展示;按平台显示 Command 或 Ctrl;仅展示、不注册快捷键。 | - -### 4.3 基础输入 · 10 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 021 | Button | 主按钮、次按钮、描边、无底色、危险变体;图标;加载与禁用。 | -| 022 | IconButton | 仅图标按钮;必填操作名称;提示文字;加载与禁用。 | -| 023 | ButtonGroup | 横纵组合;统一边界与尺寸;组内方向键移动;逐项禁用。 | -| 024 | TextInput | 单行编辑;占位、前后插槽、清空;选择、撤销、剪贴板与输入法。 | -| 025 | TextArea | 多行编辑;最小与最大高度;滚动;字数提示;提交快捷键可配置。 | -| 026 | NumberInput | 数值输入与步进;范围、步长、精度、空值;支持仅步进按钮模式。 | -| 027 | PasswordInput | 密码遮蔽;显隐切换;粘贴;默认不朗读或记录密码内容。 | -| 028 | SearchInput | 搜索与清空;提交;查找栏模式提供匹配数量、上一项和下一项。 | -| 029 | Checkbox | 选中、未选、半选;关联标签;组内全选;键盘操作。 | -| 030 | RadioGroup | 横纵单选组;稳定选项值;方向键选择;逐项禁用。 | - -### 4.4 选择控件 · 10 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 031 | Switch | 开关值;标签;禁用;切换后回传结果;不自行调用业务接口。 | -| 032 | Toggle | 保持按下状态的按钮;文字或图标;选中状态可辨认。 | -| 033 | ToggleGroup | 单选或多选;是否允许空选;键盘导航;逐项禁用。 | -| 034 | Slider | 数值范围、步长、刻度、值提示;拖动、方向键、Home 和 End。 | -| 035 | RangeSlider | 双端范围;步长;两端不交叉;分别提供焦点和数值说明。 | -| 036 | Select | 单选列表;占位、清空、禁用项、空状态;方向键与输入定位。 | -| 037 | MultiSelect | 多选、搜索、全选、数量限制;标签折叠;无结果状态。 | -| 038 | Combobox | 输入筛选与选项选择;可允许自定义值;异步结果由调用方提供。 | -| 039 | Cascader | 多级选择;完整路径;叶节点限制;外部加载子项;加载失败重试。 | -| 040 | TreeSelect | 树形单选或多选;搜索、展开、半选;父子选择规则可配置。 | - -### 4.5 表单组合 · 10 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 041 | Form | 字段注册;校验;提交、重置;提交中防重入;错误字段定位。 | -| 042 | FormField | 标签、必填标记、说明、错误;标签关联控件;上下或左右布局。 | -| 043 | InputGroup | 输入框与前后文字、按钮组合;统一边框;各操作保留独立焦点。 | -| 044 | PinInput | 位数限制;输入与退格;整段粘贴;完成事件;允许前导零。 | -| 045 | TagsInput | 标签增删、粘贴分隔、去重、数量限制;提交前校验。 | -| 046 | Rating | 评分与只读展示;步长配置;键盘调整;数值说明。 | -| 047 | ColorPicker | HEX、RGB、透明度;颜色选择;预设色;输入校验;透明度背景格。 | -| 048 | FilePicker | 调用系统文件或目录选择器;单选、多选、扩展名筛选;取消不报错。 | -| 049 | FileDropZone | 文件拖入提示;文件列表;类型、数量和大小检查;移除与错误提示。 | -| 050 | Transfer | 两侧列表;搜索、选择、移动、批量移动;禁用项;保留稳定条目 ID。 | - -### 4.6 日期与时间 · 8 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 051 | Calendar | 月视图;日期选择;今日标记;不可选日期;周起始日可配置。 | -| 052 | DatePicker | 日期输入与日历弹层;格式、范围、清空;无效输入提示。 | -| 053 | DateRangePicker | 起止日期;范围预览;起止限制;确认与清空。 | -| 054 | TimePicker | 时分秒;12/24 小时;步长与范围;输入与选择。 | -| 055 | TimeRangePicker | 起止时间;范围校验;跨午夜须显式开启。 | -| 056 | DateTimePicker | 日期与时间组合;时区由调用方指定;范围限制;确认与取消。 | -| 057 | MonthPicker | 年份切换;月份选择;范围、禁用与清空。 | -| 058 | YearPicker | 年份区间浏览;键盘选择;范围、禁用与清空。 | - -### 4.7 导航 · 10 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 059 | Tabs | 标签与面板关联;横纵布局;方向键导航;禁用项;溢出滚动。 | -| 060 | SegmentedControl | 分段单选;文字或图标;等宽或自适应;键盘导航。 | -| 061 | Breadcrumb | 路径导航;当前项;中间项折叠;完整名称提示。 | -| 062 | Pagination | 页码、上一页、下一页、页容量、跳页;总数变化时修正有效页码。 | -| 063 | Steps | 当前、完成、等待、错误;横纵布局;是否可点击由调用方控制。 | -| 064 | Sidebar | 分组与嵌套;图标、计数、选中项;展开折叠;紧凑模式。 | -| 065 | NavigationMenu | 层级导航;当前路径;可选前进、后退按钮;接入已有导航状态。 | -| 066 | Menubar | 应用窗口内菜单栏;子菜单;勾选和单选项;快捷键提示;键盘导航。 | -| 067 | Toolbar | 操作分组;分隔;窄宽溢出菜单;方向键导航;图标名称。 | -| 068 | CommandPalette | 快捷键打开;搜索、分组、无结果;方向键选择;执行后关闭。 | - -### 4.8 内容与数据 · 12 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 069 | Card | 标题、内容、页脚插槽;描边;可选角饰;不默认增加点击行为。 | -| 070 | List | 虚拟化列表与网格模式;稳定条目键;单选、多选;键盘导航与空状态。 | -| 071 | DataTable | 虚拟化;排序、筛选、多选;列宽、列顺序、列显隐;自定义单元格。 | -| 072 | Tree | 节点展开;选择;键盘导航;外部加载子项;空状态;大量节点虚拟化。 | -| 073 | TreeTable | 树形层级与列组合;展开、选择、列宽、排序状态;可见行虚拟化。 | -| 074 | Accordion | 单项或多项展开;是否允许全部关闭;键盘操作;展开状态受控。 | -| 075 | Collapsible | 触发区与内容区;外部控制展开;隐藏内容退出焦点序列。 | -| 076 | Timeline | 时间、标题、内容、状态;纵向排列;长内容换行。 | -| 077 | DescriptionList | 名称与值;多列、跨列、复制值;窄宽时改为单列。 | -| 078 | Statistic | 数值、单位、前后缀、精度;变化值说明;不计算业务指标。 | -| 079 | ImageViewer | 放大、缩小、平移、适应窗口、切换图片;键盘操作与关闭。 | -| 080 | Carousel | 上一项、下一项、位置指示;默认不自动播放;开启后支持暂停。 | - -### 4.9 浮层与菜单 · 10 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 081 | Dialog | 标题、说明、内容、操作区;焦点限制与恢复;关闭规则可配置。 | -| 082 | AlertDialog | 危险操作确认;默认聚焦取消;默认不允许点击遮罩关闭。 | -| 083 | Drawer | 四侧展开;尺寸限制;模态或非模态;关闭与焦点恢复。 | -| 084 | Popover | 锚点定位;自动避让窗口边缘;外部点击与 Escape 关闭。 | -| 085 | Tooltip | 悬停或聚焦显示;延迟;边缘避让;不放交互内容。 | -| 086 | HoverCard | 悬停或聚焦预览;允许移入内容;键盘可进入;延迟关闭。 | -| 087 | DropdownMenu | 按钮触发菜单;分组、子菜单、勾选、禁用;操作后按规则关闭。 | -| 088 | ContextMenu | 应用内右键菜单;键盘触发;按指针或焦点定位;边缘避让。 | -| 089 | Popconfirm | 锚点旁确认与取消;说明关联;可聚焦操作;不替代高风险确认框。 | -| 090 | Overlay | 窗口内覆盖层;定位、遮罩、模态行为;与现有 MyGo 浮层协作。 | - -### 4.10 反馈与状态 · 10 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 091 | Alert | 信息、成功、警告、错误;标题、内容、操作;可关闭。 | -| 092 | Toast | 消息队列;去重 ID;操作、关闭、时长;悬停或聚焦时暂停计时。 | -| 093 | Progress | 确定与不确定进度;线形与环形;计量模式支持范围和阈值。 | -| 094 | Spinner | 加载标记;尺寸与说明;隐藏或停止加载后停止动画。 | -| 095 | Skeleton | 文字、圆形、矩形占位;固定内容尺寸;减少动画模式使用静态填充。 | -| 096 | EmptyState | 标题、说明、可选图标与操作;区分无数据和无搜索结果。 | -| 097 | Result | 成功、失败、无权限等结果页;说明与下一步操作;不执行业务跳转。 | -| 098 | StatusIndicator | 状态标记与文字;成功、警告、错误、离线、处理中;不能只靠颜色。 | -| 099 | NotificationCenter | 应用内消息列表;已读、未读、清除、操作;消息与持久化由调用方管理。 | -| 100 | LoadingOverlay | 容器范围加载遮罩;阻止该范围重复操作;说明;可选取消动作。 | - -### 4.11 上游组件归并 - -MyGo 已有能力按以下方式纳入清单,不增加计数。其余同类组件按第 2 节复用。[^native] - -| MyGo 能力 | MujicaUI 对应项 | -| --- | --- | -| Primary button、Menu button | Button 的主按钮变体、DropdownMenu | -| Stepper、Number input | NumberInput 的步进模式与输入模式 | -| Search field、Find bar | SearchInput 的搜索模式与查找栏模式 | -| Autocomplete、Combobox | Combobox 的自由输入与选项选择模式 | -| Token field、Editable text | TagsInput、Text 的可编辑模式 | -| List、Grid view | List 的列表模式与网格模式 | -| Table、Outline、Tree | DataTable、TreeTable、Tree | -| Date input、Time input | DatePicker、TimePicker | -| Back and forward buttons | NavigationMenu 的历史导航操作 | -| Meter、Progress bar | Progress 的计量模式与进度模式 | - -## 5. 交互与状态 - -### 5.1 通用状态 - -| 状态 | 要求 | -| --- | --- | -| 默认 | 内容、边界与可操作性可辨认。 | -| 悬停 | 改变背景或边界,不改变布局尺寸。 | -| 按下 | 显示按下反馈;释放在无效区域时不误触发。 | -| 聚焦 | 键盘焦点可见;选中或错误状态不能覆盖焦点环。 | -| 选中 | 同时使用标记、形状或文字,不只改变颜色。 | -| 禁用 | 不修改值,不执行操作,退出常规 Tab 序列;仍向辅助技术报告禁用。 | -| 只读 | 不修改值;文字仍可聚焦、选择和复制;不得等同于禁用。 | -| 加载 | 保留尺寸与标签;阻止重复提交;完成或失败后恢复操作。 | -| 错误 | 保留输入;显示具体字段错误;提供修正或重试入口。 | - -只为适用组件提供对应状态。展示与布局组件不添加无用途的 Loading、Disabled 等参数。 - -### 5.2 键盘与无障碍 - -所有操作必须能通过键盘完成。Tab 与 Shift+Tab 跨组移动;方向键在 Tabs、菜单、单选组和工具栏内部移动。快捷键遵循平台习惯,不抢占文本编辑和输入法按键。MyGo 已提供焦点组、平台快捷键映射和输入法处理,封装时不得移除。[^input] - -无文字的操作必须提供名称。字段关联标签、说明与错误。复合组件提供对应角色、值、选中、展开和禁用状态。装饰不进入无障碍树。沿用 MyGo 的角色、Label、Description 与 Error 机制。[^accessibility] - -项目对比度下限:正常文字与占位文字 4.5:1;用于识别控件、状态和焦点的图形 3:1。纯装饰边框与禁用状态不按正文处理。采用 WCAG 的对比度计算方法;不以此代替整套无障碍验收。[^text-contrast][^ui-contrast] - -### 5.3 输入与表单 - -文本组件必须保留中文输入法、组合输入、选择、剪贴板、撤销和重做。输入法组合期间不自动提交、不跳到下一格、不拆分尚未提交的文本。PasswordInput 不在日志、测试报告或无障碍值中泄露明文。 - -Form 支持同步校验与调用方提供的异步校验结果;支持失焦和提交时校验。首次提交前不持续显示未触碰字段错误。提交失败保留所有值并定位第一个错误字段。异步旧结果不得覆盖更新后的字段值;提交中不得重复触发。 - -Form 不自带业务模型、远程接口或持久化。跨字段规则由调用方提供。重置恢复调用方给定的初始值,并清除提交与校验状态。 - -### 5.4 数据组件 - -DataTable 以稳定行 ID 和列 ID 保存状态。排序、筛选、列调整后,选择仍指向同一条数据。提供受控模式与可选本地排序、筛选辅助函数;外部数据查询由调用方负责。 - -MyGo Table 提供排序状态和界面交互,但数据仍按调用方提供的顺序显示。MujicaUI 不能只显示排序箭头而不完成排序或向调用方发出排序请求。[^table] - -List、DataTable、Tree、TreeTable 使用虚拟化;不能为全部数据逐项创建界面元素。排序与筛选只在数据或条件变化时执行,不在每帧重复执行。异步加载分别展示首次加载、追加加载、失败和无数据。 - -### 5.5 浮层与消息 - -模态窗口限制焦点并阻止背景交互;非模态浮层不锁住整个窗口。Escape 优先交给输入法和当前编辑控件,再关闭最上层可关闭浮层。关闭后恢复到触发控件;触发控件消失时回到有效父级。复用 MyGo 的浮层和焦点机制。[^overlays] - -弹层在窗口边缘翻转或限高滚动。嵌套 Select、菜单或 Popover 时,不提前关闭父 Dialog。危险操作默认聚焦取消;不得通过 Enter 在未确认目标时执行。 - -Toast 默认显示 5 秒,同一窗口最多同时显示 3 条,其余排队。含操作的消息在悬停或聚焦时暂停计时;错误消息默认保留到用户关闭。消息不得抢走正在输入的焦点。 - -### 5.6 日期、文件与本地化 - -日期选择保存日期值,不因隐式 UTC 转换改变日期。时间范围默认不跨午夜;日期时间由调用方指定时区。无法表示或无法确定的日期时间须提示,不静默修正。 - -FilePicker 只负责选择并返回结果。FileDropZone 只负责接收、校验与展示,不包含上传。文件内容读取、图片解码和网络访问不得阻塞界面线程。取消文件选择不作为错误。 - -组件内置文案提供 `zh-CN` 与 `en-US`,应用可覆盖。日期格式、周起始日、12/24 小时制和数字格式可配置。业务文案由调用方传入,不写死在组件内部。 - -## 6. API 与实现约束 - -### 6.1 API 约定 - -对外包名为 `mujica`。函数首参使用 `*ui.Context`;值通过状态指针或专用状态结构传入;配置使用有类型的 Options。基础组件返回 `*ui.Element`;复合组件按需返回命名部件或状态结果。不用 `map[string]any` 作为常规配置接口。 - -下列为 MujicaUI 的目标接口,不是 MyGo 现有接口: - -```go -func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element -func TextInput(c *ui.Context, value *string, opts TextInputOptions) *ui.Element -func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) *ui.Element -``` - -事件沿用 `.Clicked()`、`.Changed()`、`.Submitted()` 的调用习惯。一次操作只产生一次业务事件。禁用、只读、范围等会影响行为的参数须在处理输入前生效,不依赖事后换色。 - -零值 Options 提供默认样式。必填参数、有效范围和空值规则必须写入 API 文档。子内容使用回调或命名插槽,不为每种组合另建组件。原生 MyGo 组件可以与 MujicaUI 混用。 - -### 6.2 状态与生命周期 - -业务状态由应用持有;组件只保留输入、展开、焦点、动画等界面状态。临时状态使用 MyGo 已有机制,不建立第二套全局状态仓库。 - -MyGo 的视图按帧构建,Element 只属于当前帧。不得跨帧缓存 Element,也不得在 goroutine 中操作它。异步结果通过 `Window.Update` 回到界面线程;窗口关闭后取消任务或忽略结果。动态项目使用稳定键;需即时处理输入的组件通过外层有键容器保持身份。[^views] - -缓存只覆盖字体、图标、装饰路径和必要的布局结果。主题、缩放、字体或数据变化时使相关缓存失效。隐藏组件不持续请求重绘。 - -### 6.3 项目结构 - -单仓库、单 Go 模块。组件放在根包,测试与实现同目录;不按组件拆成 100 个包。 - -```text -mujicaui/ -├── go.mod -├── go.sum -├── button.go -├── button_test.go -├── ... 其余组件及对应测试 -├── theme/ 主题、设计变量与对比度测试 -├── icons/ 图标接口与必要资源 -├── internal/ 少量共享实现与上游适配 -├── examples/gallery/ 原生演示应用 -├── docs/components/ 100 个组件文档 -├── testdata/ 测试数据与视觉基准 -├── README.md -├── LICENSE -└── THIRD_PARTY_NOTICES.md -``` - -固定 MyGo 的已验证 tag 或 commit,并提交 go.mod 与 go.sum。Go 最低版本与系统依赖以该版本为准,写入兼容表。不使用浮动 main,不为 Go 库运行引入 Node.js、WebView 或浏览器依赖。 - -## 7. 平台与资源 - -Windows、macOS、Linux 均需运行同一套组件 API。允许字体栅格化、系统窗口按钮和系统对话框不同;不允许缺失组件功能。 - -TitleBar 使用 MyGo 提供的窗口控制预留区域与拖动接口,不覆盖系统按钮。原生路线在 Windows 不要求 WebView2,在 Linux 仍有 GTK 3 等系统依赖,不能把“不使用 WebView”写成“没有运行时依赖”。[^windows] - -测试 100%、125%、150%、200% 显示缩放,以及跨不同缩放显示器移动窗口。文本缩放到 200% 后,不裁掉标签、字段错误和主要操作。Gallery 默认窗口为 1200 × 800 DIP,最小为 800 × 600 DIP;窄宽时折叠导航,长表格横向滚动。 - -发布说明列出已实测的操作系统版本、CPU 架构与 Linux 会话类型。X11、原生 Wayland 和 XWayland 分别记录,不把 XWayland 结果标成原生 Wayland 支持。 - -库和 Gallery 默认离线可用,无遥测、账号和强制网络字体。应用未请求时,不读取文件内容、不连接网络、不写入业务配置。字体可由应用提供;自带资源须记录来源与许可,图标和角饰不得依赖远程请求。 - -## 8. Gallery 与文档 - -Gallery 必须使用 MujicaUI 本身构建,不用网页或截图替代原生组件。提供组件分类、中英文搜索、主题切换、密度切换和示例代码复制。 - -每个组件页面包含可交互示例、适用状态展示、参数控制、事件结果和键盘说明。事件展示不得记录密码。提供设置页、文件列表、表单提交三个组合示例,验证跨组件协作,不接业务后端。 - -文档包含安装、最小运行示例、主题配置、MyGo 兼容版本和平台依赖。每个组件单独一页,列明参数、默认值、状态、事件、键盘操作、可复制代码和限制。所有 Go 示例必须通过编译检查。 - -文档中的组件截图由原生 Gallery 或渲染测试生成。文档可作为静态站发布,但浏览器页面不宣称能运行 Go 原生组件。API 名称与代码使用英文,首版说明文档使用中文。 - -## 9. 测试与性能 - -### 9.1 功能测试 - -使用 MyGo `ui.NewTester` 执行无窗口点击、键盘、输入、滚动与状态测试;使用图像快照检查渲染。MyGo 测试接口还提供缩放、主题、偏好和输入法组合模拟。[^testing] - -每个组件均需覆盖适用的正常、边界、错误与键盘路径。复用上游不免除封装层测试;新增样式不能破坏点击区域、焦点与无障碍名称。 - -快照覆盖全部组件的浅色、深色默认状态。六个视觉基准组件额外覆盖三种密度和全部适用交互状态。各平台使用独立字体与快照基准,不要求不同系统逐像素相同。 - -真机补测系统文件选择、窗口控制、中文输入法、跨屏缩放与屏幕阅读器。无窗口测试不能替代这些项目。发布测试记录包含 VoiceOver、Windows Narrator 或 NVDA,以及 Linux Orca 的结果。 - -### 9.2 性能要求 - -以下为验收目标,不是已测量结果。三个平台各固定一台基准设备,记录 CPU、GPU、内存、系统、驱动、字体、显示缩放与 MyGo 版本;使用发布构建,关闭 Inspector。 - -| 场景 | 验收要求 | -| --- | --- | -| 大列表 | 100,000 条固定高度记录可滚动、搜索和选择;每帧创建的元素数量由可见区域与预取范围决定。 | -| 大表格 | 100,000 行、10 列纯文本数据;排序与筛选后保留有效选择;不逐帧重算排序。 | -| 大树 | 100,000 个节点;折叠子树不创建界面元素;展开、搜索与滚动不阻塞输入。 | -| 渲染开销 | 同设备、同尺寸、同内容,对有等价上游实现的控件比较;MujicaUI 的界面构建与绘制 P95 耗时增幅不超过 20%。 | -| 空闲 | 关闭加载和过渡动画后,连续观察 60 秒,无组件定时器或自身逻辑持续触发重绘。 | -| 生命周期 | 连续打开、关闭同一弹层 100 次;回收后无持续增加的组件实例、后台任务或事件订阅。 | - -持续集成执行格式检查、静态检查、单元测试、示例编译与快照检查。支持的环境运行竞态检测。平台集成与性能结果随发布保存,不用单一平台的结果代替三平台验证。 - -## 10. 发布验收 - -代码采用 MIT 许可证。仓库保留第三方声明;任何复制的上游代码须保留其声明。发布 Go 模块、源码、三平台 Gallery 构建产物、文档和兼容表。 - -| 验收项 | 通过条件 | -| --- | --- | -| 数量 | 001—500 均有实现、文档、Gallery 示例与测试;变体不重复计数。 | -| 功能 | 每项最低功能可操作;不存在占位按钮、空事件或仅静态展示的交互组件。 | -| 样式 | 全部组件覆盖浅深主题;无 MyGo 默认外观混入;系统控制的界面按已声明例外处理。 | -| 主题与密度 | 切换无需重启;业务状态、焦点与滚动位置不丢失;无文字裁切。 | -| 输入与无障碍 | 键盘、中文输入法、焦点恢复、标签关联与屏幕阅读器测试通过。 | -| 数据与生命周期 | 大数据测试通过;无状态串行、重复提交、后台任务或订阅泄漏。 | -| 三平台 | 兼容表内的平台及架构均完成构建和实机验证;限制写入对应文档。 | -| 交付 | 固定依赖、示例可编译、Gallery 可运行、默认离线可用、资源许可齐全。 | - -首次公开发布须满足全部验收项,不以占位组件或后续补齐代替首版范围。 - -## 参考资料 - -上游能力核验日期:2026-10-05。实现与发布以固定依赖版本的代码为准。 - -[^native]: MyGo — [Native UI](https://mygo.egoist.dev/docs/ui). -[^bases]: MyGo — [Custom widgets](https://mygo.egoist.dev/docs/ui/custom-widgets). -[^theme]: MyGo — [Styling and themes](https://mygo.egoist.dev/docs/ui/styling). -[^text]: MyGo — [Text](https://mygo.egoist.dev/docs/ui/text). -[^input]: MyGo — [Input](https://mygo.egoist.dev/docs/ui/input). -[^accessibility]: MyGo — [Accessibility](https://mygo.egoist.dev/docs/ui/accessibility). -[^table]: MyGo — [Table](https://mygo.egoist.dev/docs/ui/table). -[^overlays]: MyGo — [Overlays](https://mygo.egoist.dev/docs/ui/overlays). -[^views]: MyGo — [Views](https://mygo.egoist.dev/docs/ui/views). -[^windows]: MyGo — [Windows with native UI](https://mygo.egoist.dev/docs/ui/windows). -[^testing]: MyGo — [Testing](https://mygo.egoist.dev/docs/ui/testing). -[^text-contrast]: W3C — [Contrast (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html). -[^ui-contrast]: W3C — [Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html). diff --git a/TASKS.md b/TASKS.md deleted file mode 100644 index d3c27b5..0000000 --- a/TASKS.md +++ /dev/null @@ -1,47 +0,0 @@ -# TASKS - -Ordered by PRD sections. Check an item only after its codex review passes. Review rounds noted as `r1`, `r2`, `r3`. - -- [x] T01 §1–2 Module, MyGo v0.2.10 pin, reuse rules, AGENTS.md — r1 FAIL (line height, enum checks), r2 PASS -- [x] T02 §3 Tokens, density, type, motion, contrast tests, `Use`, icons, i18n — r1 FAIL, r2 PASS -- [x] T03 §4.1 Layout 001–010 — r1 FAIL 8, r2 FAIL 2, r3 FAIL 1 (folded focus); fixed b34958b, verified by coordinator red/green after cap -- [x] T04 §4.2 Display 011–020 — r1 FAIL 5, r2 FAIL 5, r3 FAIL 2 (image sizing, Retry clip); fixed e922651, verified by coordinator tests and snapshot after cap -- [x] T05 §4.3 Inputs 021–030 — r1 FAIL 10, r2 FAIL 5, r3 FAIL 2 (float grid, password in test output); fixed 07a90f4, verified by coordinator red/green after cap -- [x] T06 §4.4 Choices 031–040 — r1 FAIL 16, r2 FAIL 5, r3 FAIL 1 (tiny-step direction); fixed 9e78900, verified by coordinator red/green after cap -- [x] T07 §4.5 Forms 041–050 — r1 FAIL 7, r2 FAIL 4, r3 FAIL 1 (disabled cut); fixed 49b3b78, coordinator red/green verified -- [x] T08 §4.6 Date and time 051–058 — r1 FAIL 9, r2 FAIL 5, r3 FAIL 2 (popup pick commit, selection mark); fixed e3eaef7, coordinator red/green verified -- [x] T09 §4.7 Navigation 059–068 — r1 FAIL 5, r2 FAIL 5, r3 FAIL 1 (Tab out of menubar); fixed 7e4d862, coordinator red/green verified -- [x] T10 §4.8 Content and data 069–080 — r1 FAIL 9, r2 FAIL 4, r3 PASS -- [x] T11 §4.9 Overlays and menus 081–090 — r1 FAIL 12, r2 FAIL 5, r3 PASS -- [x] T12 §4.10 Feedback 091–100 — r1 FAIL 5, r2 FAIL 4, r3 PASS -- [ ] T12.01 §4.12 Charts I 101–120 (docs/prd) -- [ ] T12.02 §4.13 Charts II 121–140 (docs/prd) -- [ ] T12.03 §4.14 Market Charts 141–160 (docs/prd) -- [ ] T12.04 §4.15 Trading 161–180 (docs/prd) -- [ ] T12.05 §4.16 Markets 181–200 (docs/prd) -- [ ] T12.06 §4.17 Chat Messages 201–220 (docs/prd) -- [ ] T12.07 §4.18 Chat Input 221–240 (docs/prd) -- [ ] T12.08 §4.19 Chat Sessions 241–260 (docs/prd) -- [ ] T12.09 §4.20 Agent 261–280 (docs/prd) -- [ ] T12.10 §4.21 Agent Ops & Motion 281–300 (docs/prd) -- [ ] T12.11 §4.22 Git 301–320 (docs/prd) -- [ ] T12.12 §4.23 Terminal & Code 321–340 (docs/prd) -- [ ] T12.13 §4.24 Media 341–360 (docs/prd) -- [ ] T12.14 §4.25 Projects 361–380 (docs/prd) -- [ ] T12.15 §4.26 Files & Collaboration 381–400 (docs/prd) -- [ ] T12.16 §4.27 Messaging & Mail 401–420 (docs/prd) -- [ ] T12.17 §4.28 Calendar 421–440 (docs/prd) -- [ ] T12.18 §4.29 Dashboards & Dev Tools 441–460 (docs/prd) -- [ ] T12.19 §4.30 Settings & Account 461–480 (docs/prd) -- [ ] T12.20 §4.31 Essentials 481–500 (docs/prd) -- [ ] T13 §4.11 Upstream mapping documented -- [ ] T14 §5 States, keyboard, a11y, input, overlays: cross-cutting tests -- [ ] T15 §6 API and lifecycle audit; README, LICENSE, THIRD_PARTY_NOTICES -- [ ] T16 §7 Platform table, offline, resources -- [ ] T17 §8 Native Gallery: categories, zh/en search, theme, density, code copy, three composite demos -- [ ] T18 §8 Web mirror of the Gallery (user request) -- [ ] T19 §8 Docs: install, minimal example, theme, compat; 100 component pages; examples compile -- [ ] T20 §9 Snapshots, density baselines, large-data and lifecycle tests -- [ ] T21 §9 CI workflow -- [ ] T22 §6.3 Split into category sub-packages after 001–500 land (user decision 2026-10-05); update PRD §6.3, docs, Gallery, CI -- [ ] T23 §10 Acceptance checklist diff --git a/candle_countdown.go b/candle_countdown.go index 7f5f566..e4561c5 100644 --- a/candle_countdown.go +++ b/candle_countdown.go @@ -11,17 +11,22 @@ import ( // CandleCountdownOptions configure a CandleCountdown. type CandleCountdownOptions struct { - // Location aligns days and weeks; nil is UTC, as exchanges count. + // Location defaults to UTC. Location *time.Location + // Now freezes the clock; zero uses the frame time. + Now time.Time } -// CandleCountdown shows the time left until the current candle of interval closes; it wakes once a second only while shown. +// CandleCountdown displays closing time and schedules visible updates. func CandleCountdown(c *ui.Context, interval Interval, opts CandleCountdownOptions) *ui.Element { loc := opts.Location if loc == nil { loc = time.UTC } - now := c.Now() + now := opts.Now + if now.IsZero() { + now = c.Now() + } left := interval.Close(now, loc).Sub(now) k := Tokens(c) text := candleCountdownText(left) @@ -31,7 +36,9 @@ func CandleCountdown(c *ui.Context, interval Interval, opts CandleCountdownOptio ui.Text(c, text).FontFeatures("tnum").FontWeight(500).TextColor(k.Text).SingleLine() }) // The next whole second, so the text never skips one. - c.After(left - left.Truncate(time.Second) + time.Millisecond) + if opts.Now.IsZero() { + e.DrawOver(func(_ *ui.Painter, _ ui.Rect) { c.After(left - left.Truncate(time.Second) + time.Millisecond) }) + } return e } diff --git a/candle_countdown_test.go b/candle_countdown_test.go new file mode 100644 index 0000000..2b42c7b --- /dev/null +++ b/candle_countdown_test.go @@ -0,0 +1,41 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" + "time" +) + +func TestCandleCountdownIntervalsAndClock(t *testing.T) { + now := time.Date(2026, 10, 5, 12, 34, 5, 0, time.UTC) + iv := Interval("1m") + tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); CandleCountdown(c, iv, CandleCountdownOptions{Now: now}) }, 300, 100) + if !tt.HasText("00:55") { + t.Fatalf("countdown %q", tt.Texts()) + } + iv = "1h" + tt.Frame() + if !tt.HasText("25:55") { + t.Fatalf("hour %q", tt.Texts()) + } + for _, period := range Intervals { + if !period.Close(now, time.UTC).After(now) { + t.Fatalf("close %s", period) + } + } + for _, tc := range []struct { + d time.Duration + want string + }{{time.Second, "00:01"}, {time.Hour + time.Second, "1:00:01"}, {48*time.Hour + time.Hour, "2d 01:00:00"}} { + if got := candleCountdownText(tc.d); got != tc.want { + t.Fatalf("text %q", got) + } + } +} +func TestCandleCountdownSnapshot(t *testing.T) { + snapshot(t, "candle_countdown", 300, 80, func(c *ui.Context) { + ui.Row(c).Padding(12).Children(func() { + CandleCountdown(c, "1h", CandleCountdownOptions{Now: time.Date(2026, 10, 5, 12, 34, 5, 0, time.UTC)}) + }) + }) +} diff --git a/candlestick_chart.go b/candlestick_chart.go index 94180b0..0d05997 100644 --- a/candlestick_chart.go +++ b/candlestick_chart.go @@ -12,7 +12,7 @@ import ( "mujicaui/theme" ) -// ChartType is how a price chart draws candles. The zero value is ChartCandles. +// ChartType selects the price chart shape. type ChartType int const ( @@ -31,26 +31,29 @@ type CandlestickOptions struct { Decimals int // Overlays draw indicators over the prices. Overlays []IndicatorOverlay - // Compare overlays other symbols in percent; the chart turns to percent while any show. + // Compare switches the chart to normalized percent prices. Compare *[]CompareSymbol - // Alerts are draggable price lines; OnAlert runs when the last close crosses one. + // Alerts provides draggable lines and price-crossing callbacks. Alerts *[]PriceAlertLine OnAlert func(a PriceAlertLine, last Candle) // Drawings, when set, takes clicks for the current tool. Drawings *DrawingTools - // Sync links the view and crosshair to other charts under SyncID. + // Sync links the window and crosshair under SyncID. Sync *ChartSync SyncID string } // mktState is a price chart's own frame-to-frame state. type mktState struct { - drawn int // candles painted last frame - dragRest float32 // pan drag left over, under a candle - lastClose float64 - alertSeen bool - grab int // alert index dragged, -1 none - focus int // keyboard mark last frame, to follow only when it moves + drawn int // candles painted last frame + dragRest float32 // pan drag left over, under a candle + lastClose float64 + alertSeen bool + grab int // alert index dragged, -1 none + focus int // keyboard mark last frame, to follow only when it moves + alertFocus int + plot ui.Rect + ys internal.Linear // the price scale as last painted } // mktCore is one price chart being built. @@ -77,7 +80,7 @@ type mktLine struct { shape int } -// CandlestickChart draws candles of the view's window, building and painting only those; rises and falls use the window's Trend colors and hollow or filled bodies. +// CandlestickChart paints visible candles with hollow rises and filled falls. func CandlestickChart(c *ui.Context, candles []Candle, view *MarketView, opts CandlestickOptions) ChartResult { if view == nil { panic("mujica: CandlestickChart needs a view") @@ -97,7 +100,14 @@ func CandlestickChart(c *ui.Context, candles []Candle, view *MarketView, opts Ca return candles }) m := &mktCore{c: c, k: Tokens(c), ch: ch, candles: shown, view: view, opts: opts, dec: priceDecimals("CandlestickChart", opts.Decimals)} - m.st = *ui.Local(ch.res.Element, "mujica.mkt", func() *mktState { return &mktState{grab: -1, focus: -1} }) + m.st = *ui.Local(ch.res.Element, "mujica.mkt", func() *mktState { return &mktState{grab: -1, focus: -1, alertFocus: -1} }) + if ch.res.Element.IsDisabled() { + copy := *view + view = © + m.view = view + m.opts.Sync = nil + opts.Sync = nil + } if s := opts.Sync; s != nil { s.load(opts.SyncID, shown, view) } @@ -122,7 +132,7 @@ func OHLCChart(c *ui.Context, candles []Candle, view *MarketView, opts Candlesti return CandlestickChart(c, candles, view, opts) } -// HeikinAshiChart is CandlestickChart of Heikin-Ashi candles computed from candles; *raw switches back to them. +// HeikinAshiChart switches between smoothed candles and raw OHLC. func HeikinAshiChart(c *ui.Context, candles []Candle, view *MarketView, raw *bool, opts CandlestickOptions) ChartResult { if raw == nil { panic("mujica: HeikinAshiChart needs raw") @@ -136,8 +146,8 @@ func HeikinAshiChart(c *ui.Context, candles []Candle, view *MarketView, raw *boo col.Children(func() { ui.Row(c).Children(func() { v := []bool{*raw} - if ToggleGroup(c, &v, []ToggleItem[bool]{{Value: false, Label: msg(c, "heikin_ashi.smoothed")}, {Value: true, Label: msg(c, "heikin_ashi.raw")}}, - ToggleGroupOptions{Label: msg(c, "heikin_ashi.switch")}).Changed() { + if ToggleGroup(c, &v, []ToggleItem[bool]{{Value: false, Label: msg(c, "heikin_ashi_chart.smoothed")}, {Value: true, Label: msg(c, "heikin_ashi_chart.raw")}}, + ToggleGroupOptions{Label: msg(c, "heikin_ashi_chart.switch")}).Changed() { *raw = v[0] slog.Debug("mujica: heikin_ashi switch", "raw", *raw) } @@ -148,7 +158,7 @@ func HeikinAshiChart(c *ui.Context, candles []Candle, view *MarketView, raw *boo return res } -// window resolves the shown candles from the view and last frame's width. +// window resolves visible candles using the last measured width. func (m *mktCore) window() { m.from, m.to = marketWindow(*m.view, len(m.candles), m.room()) } @@ -162,7 +172,7 @@ func (m *mktCore) room() int { return max(10, int(w/3)) } -// yDomain spans the window's lows and highs, overlays and alerts, recomputed only when they change. +// yDomain caches visible prices, indicators, and alerts. func (m *mktCore) yDomain() { if m.to <= m.from { return @@ -195,7 +205,7 @@ func (m *mktCore) yDomain() { m.lo, m.hi = d[0], d[1] } -// val maps a price to what the axis shows: itself, or percent from base. +// val converts price to the current axis unit. func (m *mktCore) val(p float64) float64 { if m.base == 0 { return p @@ -238,6 +248,12 @@ func (m *mktCore) spec() chartSpec { for _, l := range m.overlays { keys = append(keys, chartKey{Name: l.name, Color: l.color, Shape: l.shape}) } + if n == 0 && m.opts.Compare != nil { + pal := internal.Palette(m.k) + for i, s := range *m.opts.Compare { + keys = append(keys, chartKey{Name: s.Name, Color: pal[(i+1)%len(pal)], Shape: i + 1}) + } + } spec := chartSpec{n: n, keys: keys} if m.to <= m.from { spec.n = 0 @@ -256,12 +272,19 @@ func (m *mktCore) spec() chartSpec { } return min(i+1, n-1) } + if d := m.opts.Drawings; d != nil { + spec.keyActivate = func(i int) { + if !d.Locked && d.Tool != ToolSelect { + d.place(m, DrawingPoint{m.candles[i].Time, m.candles[i].Close}) + } + } + } spec.readout = m.readout spec.draw = func(p *ui.Painter, r ui.Rect, active int) { m.paint(p, r, active) } return spec } -// readout says the candle's time, prices, change and direction, and the overlays there. +// readout describes time, OHLC, volume, direction, and indicators. func (m *mktCore) readout(i int) string { c := m.candles[i] f := func(v float64) string { return priceUngrouped(v, m.dec) } diff --git a/candlestick_chart_test.go b/candlestick_chart_test.go index 505f68f..6b4c554 100644 --- a/candlestick_chart_test.go +++ b/candlestick_chart_test.go @@ -41,7 +41,6 @@ func TestCandlestickChartDrawsOnlyVisible(t *testing.T) { if st.drawn != 120 { t.Fatalf("drew %d candles of 100000", st.drawn) } - // Zooming out is capped by the width: three DIPs a candle. tt.Key(0, ui.KeyTab) for range 40 { tt.Key(0, ui.KeyMinus) diff --git a/candlestick_paint.go b/candlestick_paint.go index 5bc89d1..436f8f7 100644 --- a/candlestick_paint.go +++ b/candlestick_paint.go @@ -10,9 +10,10 @@ import ( "mujicaui/theme" ) -// paint draws axes, the window's candles, layers and the crosshair at active. +// paint draws axes, visible candles, layers, and the crosshair. func (m *mktCore) paint(p *ui.Painter, r ui.Rect, active int) { g := m.geom(r) + m.st.plot, m.st.ys = g.plot, g.ys k := m.k size := FontSize(m.c, theme.CaptionSize) label := func(v float64) string { @@ -25,8 +26,8 @@ func (m *mktCore) paint(p *ui.Painter, r ui.Rect, active int) { m.paintTimeAxis(p, g, size) p.Clip(g.plot, 0, func() { m.st.drawn = m.paintCandles(p, g) - for i, l := range m.overlays { - mktPaintLine(p, g, m, l.values, l.color, i) + for _, l := range m.overlays { + mktPaintLine(p, g, m, l.values, l.color, l.shape) } m.paintAlerts(p, g, size) if d := m.opts.Drawings; d != nil { @@ -49,7 +50,7 @@ func (m *mktCore) paint(p *ui.Painter, r ui.Rect, active int) { // paintTimeAxis labels candles about 90 DIPs apart. func (m *mktCore) paintTimeAxis(p *ui.Painter, g mktGeom, size float32) { every := max(1, int(math.Ceil(float64(90/g.band.Step())))) - layout := "Jan 2" + layout := msg(m.c, "candlestick_chart.daily_time") if m.to-m.from > 1 && m.candles[m.from+1].Time.Sub(m.candles[m.from].Time) < 24*time.Hour { layout = "15:04" } @@ -62,18 +63,14 @@ func (m *mktCore) paintTimeAxis(p *ui.Painter, g mktGeom, size float32) { internal.AxisX(p, g.plot, xs, labels, size, m.k) } -// paintCandles draws the window as the chart's type and returns how many candles it drew. +// paintCandles returns the number of visible candles painted. func (m *mktCore) paintCandles(p *ui.Painter, g mktGeom) int { k := m.k w := max(1, g.band.Width()) if m.opts.Type == ChartLine || m.opts.Type == ChartArea { - closes := make([]float64, len(m.candles)) - for i := m.from; i < m.to; i++ { - closes[i] = m.val(m.candles[i].Close) - } var line, fill ui.Path for i := m.from; i < m.to; i++ { - x, y := internal.Snap(p, g.x(m, i)), internal.Snap(p, g.ys.Map(closes[i])) + x, y := internal.Snap(p, g.x(m, i)), internal.Snap(p, g.ys.Map(m.val(m.candles[i].Close))) if i == m.from { line.MoveTo(x, y) fill.MoveTo(x, g.plot.Y+g.plot.H).LineTo(x, y) @@ -109,7 +106,7 @@ func (m *mktCore) paintCandles(p *ui.Painter, g mktGeom) int { body := ui.Rect{X: x - w/2, Y: top, W: w, H: max(1, bot-top)} p.Line(x, yh, x, top, 1, col) p.Line(x, bot, x, yl, 1, col) - // Rises are hollow and falls filled, so direction reads without color. + // Rises are hollow; falls are filled. if up && w >= 3 { p.Fill(body, k.Surface, 0) p.Stroke(body, col, 0, 1) @@ -120,7 +117,7 @@ func (m *mktCore) paintCandles(p *ui.Painter, g mktGeom) int { return m.to - m.from } -// mktPaintLine draws values over the window with sparse markers of shape. +// mktPaintLine draws visible values with distinct series markers. func mktPaintLine(p *ui.Painter, g mktGeom, m *mktCore, values []float64, col ui.Color, shape int) { var path ui.Path open := false @@ -147,7 +144,7 @@ func mktPaintLine(p *ui.Painter, g mktGeom, m *mktCore, values []float64, col ui } } -// mktTag draws a price label at the plot's right edge, centered on y. +// mktTag centers a price label on the right edge. func mktTag(p *ui.Painter, right, y float32, s string, size float32, fg, bg ui.Color) { w, h := p.MeasureText(0, ui.Span{Text: s, Size: size}) b := ui.Rect{X: right - w - 8, Y: y - h/2 - 2, W: w + 8, H: h + 4} diff --git a/chart_frame.go b/chart_frame.go index 9fdad75..2005a50 100644 --- a/chart_frame.go +++ b/chart_frame.go @@ -65,7 +65,8 @@ type chartSpec struct { // move steps from mark i; nil steps in order. move func(i int, key ui.Key) int // activate runs on Enter, Space or a click. - activate func(i int) + keyActivate func(i int) + activate func(i int) } type chartState struct { @@ -116,7 +117,7 @@ func (ch *chart) build(spec chartSpec) (ChartResult, *ui.Element) { }) }) ch.res.Point = -1 - ui.Box(c).Height(u * 5) + ui.Box(c).Height(max(u*5, FontSize(c, theme.CaptionSize)*theme.LineHeight)) return } plot = box.Focusable().Label(ch.frame.Label).Role(ui.RoleGroup) @@ -137,7 +138,7 @@ func (ch *chart) build(spec chartSpec) (ChartResult, *ui.Element) { if active >= 0 { text, col = spec.readout(active), k.Text } - ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize)).TextColor(col).Role(ui.RoleStatus).SingleLine().Height(u * 5) + ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize)).TextColor(col).Role(ui.RoleStatus).SingleLine().Height(max(u*5, FontSize(c, theme.CaptionSize)*theme.LineHeight)) }) ch.res.changed = ch.res.Point != st.last st.last = ch.res.Point @@ -183,6 +184,9 @@ func (ch *chart) input(plot *ui.Element, spec chartSpec) { slog.Debug("mujica: chart focus", "label", ch.frame.Label, "mark", to) } act := st.focus >= 0 && (plot.Shortcut(0, ui.KeyEnter) || plot.Shortcut(0, ui.KeySpace)) + if act && spec.keyActivate != nil { + spec.keyActivate(st.focus) + } if plot.Clicked() { plot.Focus() x, y, _ := plot.PointerPosition() diff --git a/chart_sync.go b/chart_sync.go index a8170ad..82e43ab 100644 --- a/chart_sync.go +++ b/chart_sync.go @@ -2,6 +2,7 @@ package mujica import ( "log/slog" + "sort" "time" "github.com/egoist/mygo/ui" @@ -10,22 +11,26 @@ import ( "mujicaui/theme" ) -// ChartSync links charts by time: the same window, zoom and crosshair. The caller holds it. +// ChartSync shares time windows and crosshairs across linked charts. type ChartSync struct { - // Count is the linked zoom in candles; zero is the default 120. + // Count is linked zoom; zero defaults to 120. Count int - // End is the newest linked time shown; zero follows each chart's newest candle. + // End is inclusive; zero follows each chart's latest candle. End time.Time + // Start is the inclusive left edge across candle intervals. + Start time.Time // Cursor is the crosshair time; zero shows none. Cursor time.Time // Unlinked charts keep their own view and crosshair. - Unlinked map[string]bool - owner string + Unlinked map[string]bool + owner string + rangeOwner string + loaded map[string]MarketView } func (s *ChartSync) linked(id string) bool { return !s.Unlinked[id] } -// load puts the linked window into view before a chart builds. +// load applies the shared time window to linked charts. func (s *ChartSync) load(id string, candles []Candle, view *MarketView) { if !s.linked(id) { return @@ -36,17 +41,44 @@ func (s *ChartSync) load(id string, candles []Candle, view *MarketView) { view.End = max(1, i+1) } } + if s.rangeOwner == "" { + s.rangeOwner = id + } + if len(candles) > 0 && (s.Start.IsZero() || s.End.IsZero() && s.rangeOwner == id) { + from, _ := marketWindow(*view, len(candles), len(candles)) + s.Start = candles[from].Time + } + if !s.Start.IsZero() && len(candles) > 0 { + from := sort.Search(len(candles), func(i int) bool { return !candles[i].Time.Before(s.Start) }) + to := len(candles) + if view.End > 0 { + to = view.End + } + if from < to { + view.Count = to - from + } + } + if s.loaded == nil { + s.loaded = map[string]MarketView{} + } + s.loaded[id] = *view } -// store writes a linked chart's window and crosshair back after it builds. +// store updates the shared window and active crosshair. func (s *ChartSync) store(id string, candles []Candle, view *MarketView, active int) { if !s.linked(id) { return } - s.Count = view.Count - s.End = time.Time{} - if view.End > 0 && view.End <= len(candles) { - s.End = candles[view.End-1].Time + loaded, ok := s.loaded[id] + if !ok || *view != loaded { + s.Count, s.End, s.rangeOwner = view.Count, time.Time{}, id + if view.End > 0 && view.End <= len(candles) { + s.End = candles[view.End-1].Time + } + if len(candles) > 0 { + from, _ := marketWindow(*view, len(candles), len(candles)) + s.Start = candles[from].Time + } } switch { case active >= 0: @@ -64,7 +96,7 @@ func (s *ChartSync) cursorIndex(id string, candles []Candle) int { return marketIndexAt(candles, s.Cursor) } -// Link builds a toggle that links or unlinks chart id; Changed reports a switch. +// Link toggles synchronization for a stable chart ID. func (s *ChartSync) Link(c *ui.Context, id string) ChoiceResult { if id == "" { panic("mujica: ChartSync.Link needs an id") diff --git a/chart_sync_test.go b/chart_sync_test.go new file mode 100644 index 0000000..b1547f4 --- /dev/null +++ b/chart_sync_test.go @@ -0,0 +1,54 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" +) + +func TestChartSyncRangeCursorAndUnlink(t *testing.T) { + cs := sampleCandles(300) + sync := ChartSync{} + v := MarketView{Count: 40, End: 200} + sync.store("a", cs, &v, 150) + var other MarketView + sync.load("b", cs, &other) + if other != v || sync.cursorIndex("b", cs) != 150 { + t.Fatalf("sync %+v %+v", other, sync) + } + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Disabled(disabled).Children(func() { sync.Link(c, "b") }) + }, 300, 90) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if sync.linked("b") || sync.cursorIndex("b", cs) != -1 { + t.Fatal("unlink failed") + } + other = MarketView{Count: 12} + sync.load("b", cs, &other) + if other.Count != 12 { + t.Fatal("unlinked view overwritten") + } + disabled = true + tt.Frame() + tt.Key(0, ui.KeySpace) + if sync.linked("b") { + t.Fatal("disabled link changed") + } + sync.store("a", cs, &v, -1) + if !sync.Cursor.IsZero() { + t.Fatal("cursor remained after leaving owner") + } +} +func TestChartSyncSnapshot(t *testing.T) { + cs := sampleCandles(50) + sync := ChartSync{Cursor: cs[25].Time, owner: "a"} + v := MarketView{Count: 50} + snapshot(t, "chart_sync", 640, 340, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + sync.Link(c, "b") + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Linked", Height: 230}, Sync: &sync, SyncID: "b"}) + }) + }) +} diff --git a/chart_type_switcher.go b/chart_type_switcher.go index 7102cf4..ee105af 100644 --- a/chart_type_switcher.go +++ b/chart_type_switcher.go @@ -2,6 +2,7 @@ package mujica import ( "fmt" + "log/slog" "github.com/egoist/mygo/ui" ) @@ -11,7 +12,7 @@ type ChartTypeSwitcherOptions struct { Disabled bool } -// ChartTypeSwitcher chooses how CandlestickChart draws: candles, OHLC bars, line, area or Heikin-Ashi. +// ChartTypeSwitcher chooses candles, OHLC, line, area, or Heikin-Ashi. func ChartTypeSwitcher(c *ui.Context, value *ChartType, opts ChartTypeSwitcherOptions) ChoiceResult { if value == nil { panic("mujica: ChartTypeSwitcher needs a value") @@ -30,6 +31,7 @@ func ChartTypeSwitcher(c *ui.Context, value *ChartType, opts ChartTypeSwitcherOp }) if r.Changed() { *value = sel[0] + slog.Debug("mujica: chart_type_switcher changed", "type", *value) } return r } diff --git a/chart_type_switcher_test.go b/chart_type_switcher_test.go new file mode 100644 index 0000000..f810fa2 --- /dev/null +++ b/chart_type_switcher_test.go @@ -0,0 +1,41 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" +) + +func TestChartTypeSwitcherKeyboardAndDisabled(t *testing.T) { + value := ChartCandles + disabled := false + changes := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ChartTypeSwitcher(c, &value, ChartTypeSwitcherOptions{Disabled: disabled}).Changed() { + changes++ + } + }, 800, 100) + tt.Key(0, ui.KeyTab) + for _, want := range []ChartType{ChartOHLC, ChartLine, ChartArea, ChartHeikinAshi} { + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeySpace) + if value != want { + t.Fatalf("type %v, want %v", value, want) + } + } + if changes != 4 { + t.Fatalf("changes %d", changes) + } + disabled = true + tt.Frame() + tt.Key(0, ui.KeyLeft) + if value != ChartHeikinAshi || changes != 4 { + t.Fatal("disabled type changed") + } +} +func TestChartTypeSwitcherSnapshot(t *testing.T) { + value := ChartCandles + snapshot(t, "chart_type_switcher", 680, 90, func(c *ui.Context) { + ui.Row(c).Padding(12).Children(func() { ChartTypeSwitcher(c, &value, ChartTypeSwitcherOptions{}) }) + }) +} diff --git a/compare_symbol_test.go b/compare_symbol_test.go new file mode 100644 index 0000000..138c83f --- /dev/null +++ b/compare_symbol_test.go @@ -0,0 +1,71 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestCompareSymbolPercentAndRemove(t *testing.T) { + candles := sampleCandles(100) + view := MarketView{Count: 50} + eth := make([]float64, 100) + for i := range eth { + eth[i] = 20 + float64(i)*0.1 + } + cmp := []CompareSymbol{{Name: "ETH", Closes: eth}} + removed := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if CompareLegend(c, &cmp).Changed() { + removed++ + } + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}, Compare: &cmp}) + }, 800, 420) + tt.Key(0, ui.KeyTab) // remove ETH + tt.Key(0, ui.KeyTab) // the chart + tt.Key(0, ui.KeyEnd) + // Normalized from the first shown candle, index 50: (29.9/25 - 1) = 19.6%. + want := "ETH 19.60" + found := false + for _, s := range tt.Texts() { + found = found || strings.Contains(s, want) + } + if !found { + t.Fatalf("readout lacks %q: %q", want, tt.Texts()) + } + if err := tt.Click("Remove ETH"); err != nil { + t.Fatal(err) + } + if len(cmp) != 0 || removed != 1 { + t.Fatalf("remove: %v %d", cmp, removed) + } + defer func() { + if recover() == nil { + t.Fatal("short closes accepted") + } + }() + bad := []CompareSymbol{{Name: "X", Closes: []float64{1}}} + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}, Compare: &bad}) + }, 300, 200) +} + +func TestCompareSymbolSnapshot(t *testing.T) { + candles := sampleCandles(100) + view := MarketView{Count: 80} + eth, sol := make([]float64, 100), make([]float64, 100) + for i := range eth { + eth[i] = 20 + float64(i)*0.08 + float64(i%7)*0.2 + sol[i] = 5 - float64(i)*0.01 + float64(i%5)*0.05 + } + cmp := []CompareSymbol{{Name: "ETH", Closes: eth}, {Name: "SOL", Closes: sol}} + snapshot(t, "compare_symbol", 640, 360, func(c *ui.Context) { + ui.Column(c).Padding(12).Gap(8).Children(func() { + CompareLegend(c, &cmp) + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 240}, Type: ChartLine, Compare: &cmp}) + }) + }) +} diff --git a/depth_chart.go b/depth_chart.go index 9d45717..770f8e1 100644 --- a/depth_chart.go +++ b/depth_chart.go @@ -2,6 +2,8 @@ package mujica import ( "fmt" + "math" + "sort" "strings" "github.com/egoist/mygo/ui" @@ -21,14 +23,14 @@ type DepthChartOptions struct { Decimals int } -// depthData is the book's cumulative sizes, bids then asks, from the mid outward. +// depthData holds cumulative bids and asks around the midpoint. type depthData struct { price, total []float64 nBids int mid, maxSize float64 } -// DepthChart draws cumulative bid and ask depth as stepped areas around the mid price; arrows walk the levels. +// DepthChart paints cumulative depth and supports keyboard level readouts. func DepthChart(c *ui.Context, bids, asks []DepthLevel, opts DepthChartOptions) ChartResult { dec := priceDecimals("DepthChart", opts.Decimals) ch := newChart(c, opts.Frame) @@ -36,6 +38,7 @@ func DepthChart(c *ui.Context, bids, asks []DepthLevel, opts DepthChartOptions) d := chartMemo(ch, "depth", [2]string{chartID(bids), chartID(asks)}, func() depthData { return depthBuild(bids, asks) }) bidCol, askCol := trendColor(c, 1), trendColor(c, -1) keys := []chartKey{{Name: msg(c, "depth_chart.bids"), Color: bidCol, Shape: 1}, {Name: msg(c, "depth_chart.asks"), Color: askCol, Shape: 3}} + drawn := ui.Local(ch.res.Element, "mujica.depth.drawn", func() int { return 0 }) size := FontSize(c, theme.CaptionSize) geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) { plot := chartInset(r, 56, 26) @@ -47,7 +50,7 @@ func DepthChart(c *ui.Context, bids, asks []DepthLevel, opts DepthChartOptions) if len(bids) == 0 || len(asks) == 0 { spec.n = 0 } - // Left and Right walk by price: lower bids to the left, higher asks to the right. + // Arrows move by ascending price. order := func(i int) int { if i < d.nBids { return d.nBids - 1 - i @@ -68,7 +71,12 @@ func DepthChart(c *ui.Context, bids, asks []DepthLevel, opts DepthChartOptions) if !plot.Contains(x, y) { return -1 } - return internal.Nearest(len(d.price), func(i int) (float32, float32) { return xs.Map(d.price[i]), 0 }, x, 0, plot.W, true) + v := xs.Invert(x) + pos := sort.Search(len(d.price), func(i int) bool { return d.price[order(i)] >= v }) + if pos == len(d.price) || pos > 0 && v-d.price[order(pos-1)] < d.price[order(pos)]-v { + pos-- + } + return order(pos) } spec.readout = func(i int) string { side := msg(c, "depth_chart.bid") @@ -90,6 +98,7 @@ func DepthChart(c *ui.Context, bids, asks []DepthLevel, opts DepthChartOptions) } internal.AxisX(p, plot, tx, tl, size, k) base := plot.Y + plot.H + *drawn = 0 for side, idx := range [][2]int{{0, d.nBids}, {d.nBids, len(d.price)}} { col := []ui.Color{bidCol, askCol}[side] var area, edge ui.Path @@ -97,7 +106,12 @@ func DepthChart(c *ui.Context, bids, asks []DepthLevel, opts DepthChartOptions) area.MoveTo(mx, base) edge.MoveTo(mx, base) prevY := base - for i := idx[0]; i < idx[1]; i++ { + indices := chartMemo(ch, fmt.Sprint("depth.indices.", side), [3]any{chartID(d.price), plot.X, plot.W}, func() []int { + return internal.Columns(idx[1]-idx[0], func(i int) float32 { return xs.Map(d.price[idx[0]+i]) }, func(i int) float64 { return d.total[idx[0]+i] }) + }) + *drawn += len(indices) + for _, at := range indices { + i := idx[0] + at x, y := internal.Snap(p, xs.Map(d.price[i])), internal.Snap(p, ys.Map(d.total[i])) area.LineTo(x, prevY).LineTo(x, y) edge.LineTo(x, prevY).LineTo(x, y) @@ -133,15 +147,15 @@ func DepthChart(c *ui.Context, bids, asks []DepthLevel, opts DepthChartOptions) return res } -// depthBuild checks the book's order and sums sizes outward from the mid. +// depthBuild validates price order and computes cumulative volume. func depthBuild(bids, asks []DepthLevel) depthData { for i, l := range bids { - if !(l.Size > 0) || i > 0 && !(l.Price < bids[i-1].Price) { + if math.IsNaN(l.Price) || math.IsInf(l.Price, 0) || math.IsInf(l.Size, 0) || !(l.Size > 0) || i > 0 && !(l.Price < bids[i-1].Price) { panic(fmt.Sprintf("mujica: DepthChart bid %d needs a positive size and a price below the one before", i)) } } for i, l := range asks { - if !(l.Size > 0) || i > 0 && !(l.Price > asks[i-1].Price) { + if math.IsNaN(l.Price) || math.IsInf(l.Price, 0) || math.IsInf(l.Size, 0) || !(l.Size > 0) || i > 0 && !(l.Price > asks[i-1].Price) { panic(fmt.Sprintf("mujica: DepthChart ask %d needs a positive size and a price above the one before", i)) } } diff --git a/docs/components/141-candlestick-chart.md b/docs/components/141-candlestick-chart.md new file mode 100644 index 0000000..4cfdd0e --- /dev/null +++ b/docs/components/141-candlestick-chart.md @@ -0,0 +1,42 @@ +# 141 CandlestickChart + +## 用途 + +K 线;涨跌色可配置(红涨或绿涨);缩放平移;万根级只绘可见区。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +`candles` 按时间递增;`MarketView.Count=0` 显示 120 根,`End=0` 跟随最新。`Frame.Height=0` 为 240 DIP。`Type` 控制形态,`Decimals=0` 为两位。涨跌配色通过 `UseTrend` 设置。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/142-ohlc-chart.md b/docs/components/142-ohlc-chart.md new file mode 100644 index 0000000..733ae36 --- /dev/null +++ b/docs/components/142-ohlc-chart.md @@ -0,0 +1,42 @@ +# 142 OHLCChart + +## 用途 + +美国线;与 K 线共用数据接口。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.OHLCChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +与 CandlestickChart 共用 Candle、MarketView 和 CandlestickOptions;函数将 Type 设置为 ChartOHLC。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/143-heikin-ashi-chart.md b/docs/components/143-heikin-ashi-chart.md new file mode 100644 index 0000000..3893d3c --- /dev/null +++ b/docs/components/143-heikin-ashi-chart.md @@ -0,0 +1,43 @@ +# 143 HeikinAshiChart + +## 用途 + +平均 K 线;由调用方 OHLC 计算;与原 K 线可切换。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + raw := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.HeikinAshiChart(c, candles, &window, &raw, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +`raw=false` 显示 Heikin-Ashi;`raw=true` 显示原始数据。辅助函数 `HeikinAshi` 从调用方 OHLC 计算平滑序列。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/144-line-quote-chart.md b/docs/components/144-line-quote-chart.md new file mode 100644 index 0000000..7b610b2 --- /dev/null +++ b/docs/components/144-line-quote-chart.md @@ -0,0 +1,37 @@ +# 144 LineQuoteChart + +## 用途 + +分时图;昨收基线;均价线;盘前盘后分段。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + points := []mujica.QuotePoint{{Time: at, Price: 101, Volume: 100}, {Time: at.Add(time.Hour), Price: 103, Volume: 200}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LineQuoteChart(c, points, mujica.LineQuoteChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, PrevClose: 100, RegularOpen: at.Add(30 * time.Minute), RegularClose: at.Add(45 * time.Minute)}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +`PrevClose` 为必填正数;RegularOpen、RegularClose 成对提供,零值展示同一时段。均价使用 Price × Volume 累计。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/145-volume-chart.md b/docs/components/145-volume-chart.md new file mode 100644 index 0000000..9721a7e --- /dev/null +++ b/docs/components/145-volume-chart.md @@ -0,0 +1,42 @@ +# 145 VolumeChart + +## 用途 + +成交量柱;涨跌着色;与主图共享时间轴。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeChart(c, candles, &window, mujica.VolumeChartOptions{Frame: mujica.ChartFrame{Label: "Volume"}}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +传入与主图相同的 MarketView 共享时间窗口;Frame.Height 默认 120;Sync 与 SyncID 可关联光标。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/146-depth-chart.md b/docs/components/146-depth-chart.md new file mode 100644 index 0000000..11cfcf1 --- /dev/null +++ b/docs/components/146-depth-chart.md @@ -0,0 +1,37 @@ +# 146 DepthChart + +## 用途 + +买卖深度累计面积;中间价;悬停读数。 + +## 最小示例 + +```go + bids := []mujica.DepthLevel{{Price: 99, Size: 10}, {Price: 98, Size: 20}} + asks := []mujica.DepthLevel{{Price: 101, Size: 15}, {Price: 102, Size: 25}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DepthChart(c, bids, asks, mujica.DepthChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +买盘价格递减,卖盘递增,Size 为正数;最优买价小于最优卖价。Decimals 默认两位。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/147-volume-profile.md b/docs/components/147-volume-profile.md new file mode 100644 index 0000000..14cb854 --- /dev/null +++ b/docs/components/147-volume-profile.md @@ -0,0 +1,36 @@ +# 147 VolumeProfile + +## 用途 + +价格分布成交量;价值区与控制点。 + +## 最小示例 + +```go + bins := []mujica.ProfileBin{{Low: 99, High: 100, Volume: 30}, {Low: 100, High: 101, Volume: 80}, {Low: 101, High: 102, Volume: 40}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeProfile(c, bins, mujica.VolumeProfileOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +ProfileBin 按价格递增排列;Volume 非负。ValueArea 默认 0.7;VolumeProfileOf 可按 OHLC 区间生成分布。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/148-multi-chart-layout.md b/docs/components/148-multi-chart-layout.md new file mode 100644 index 0000000..718ae22 --- /dev/null +++ b/docs/components/148-multi-chart-layout.md @@ -0,0 +1,38 @@ +# 148 MultiChartLayout + +## 用途 + +1、2、4、6 宫格多图;独立品种与周期;布局切换不丢状态。 + +## 最小示例 + +```go + state := mujica.MultiChartState{Count: 2} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MultiChartLayout(c, &state, mujica.MultiChartLayoutOptions{Height: 200}, func(i int, cell *mujica.ChartCell) { + mujica.TextInput(c, &cell.Symbol, mujica.TextInputOptions{Label: "Symbol"}) + }) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +Count 支持 1、2、4、6,零值为 1;Cells 保存六个独立品种、周期与窗口;Active 指向可见单元。Height 默认 480。 + +## 状态 + +浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。 + +## 事件 + +ChoiceResult.Changed() 表示选择变化。 + +## 键盘操作 + +Tab 进入布局组,方向键移动焦点,Space 切换;Tab 进入各图表独立操作。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/149-chart-sync.md b/docs/components/149-chart-sync.md new file mode 100644 index 0000000..391f64f --- /dev/null +++ b/docs/components/149-chart-sync.md @@ -0,0 +1,44 @@ +# 149 ChartSync + +## 用途 + +多图十字光标与时间范围同步;可逐图解除。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + var sync mujica.ChartSync + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + sync.Link(c, "price") + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Sync: &sync, SyncID: "price"}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +Count、Start、End 保存共享时间窗口;不同周期按时间边界映射;Cursor 保存时间光标;Unlinked 按稳定图表 ID 解联。通过 CandlestickOptions 或 VolumeChartOptions 的 Sync 接入。 + +## 状态 + +浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。 + +## 事件 + +ChoiceResult.Changed() 表示选择变化;ChartSync.Link 返回 ChoiceResult。 + +## 键盘操作 + +Tab 聚焦 Link 开关,Space 切换关联;图表方向键移动同步光标。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/150-time-range-selector.md b/docs/components/150-time-range-selector.md new file mode 100644 index 0000000..25cfaca --- /dev/null +++ b/docs/components/150-time-range-selector.md @@ -0,0 +1,36 @@ +# 150 TimeRangeSelector + +## 用途 + +1D、5D、1M、3M、YTD、1Y、5Y、自定义;键盘导航。 + +## 最小示例 + +```go + value := mujica.MarketRange{Preset: mujica.Range1M} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TimeRangeSelector(c, &value, mujica.TimeRangeSelectorOptions{}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +MarketRange.Preset 默认 Range1D;Custom 保存 DateRange。Today 零值跟随系统日期,Bounds 使用调用方时间和时区。 + +## 状态 + +浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。 + +## 事件 + +ChoiceResult.Changed() 表示选择变化。 + +## 键盘操作 + +Tab 进入预设组,左右方向键移动焦点,Space 选择;自定义日期通过 DateRangePicker 键盘操作。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/151-interval-selector.md b/docs/components/151-interval-selector.md new file mode 100644 index 0000000..e64c82a --- /dev/null +++ b/docs/components/151-interval-selector.md @@ -0,0 +1,37 @@ +# 151 IntervalSelector + +## 用途 + +1m 至 1M 周期;分组;收藏周期。 + +## 最小示例 + +```go + value := mujica.Interval("1h") + favorites := []mujica.Interval{"1m", "1h", "1D"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IntervalSelector(c, &value, &favorites, mujica.IntervalSelectorOptions{}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +value 为 Intervals 中的周期;favorites 保存收藏列表;Disabled 禁用选择与收藏。 + +## 状态 + +浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。 + +## 事件 + +ChoiceResult.Changed() 表示选择变化。 + +## 键盘操作 + +Tab 进入收藏组,方向键移动焦点,Space 选择;Tab 遍历弹层周期和收藏按钮,Enter 激活,Escape 关闭。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/152-chart-type-switcher.md b/docs/components/152-chart-type-switcher.md new file mode 100644 index 0000000..29d8291 --- /dev/null +++ b/docs/components/152-chart-type-switcher.md @@ -0,0 +1,36 @@ +# 152 ChartTypeSwitcher + +## 用途 + +K 线、美国线、折线、面积、平均 K 线切换。 + +## 最小示例 + +```go + value := mujica.ChartCandles + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChartTypeSwitcher(c, &value, mujica.ChartTypeSwitcherOptions{}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +ChartType 默认 ChartCandles;支持 ChartOHLC、ChartLine、ChartArea、ChartHeikinAshi;Disabled 禁用操作。 + +## 状态 + +浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。 + +## 事件 + +ChoiceResult.Changed() 表示选择变化。 + +## 键盘操作 + +Tab 进入分组,左右方向键移动焦点,Space 选择。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/153-indicator-overlay.md b/docs/components/153-indicator-overlay.md new file mode 100644 index 0000000..00e4897 --- /dev/null +++ b/docs/components/153-indicator-overlay.md @@ -0,0 +1,43 @@ +# 153 IndicatorOverlay + +## 用途 + +主图叠加 MA、EMA、BOLL、VWAP;参数可配;计算由调用方或内置辅助函数。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + overlays := []mujica.IndicatorOverlay{{Kind: mujica.IndicatorMA, Params: []float64{2}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Overlays: overlays}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +Kind 支持 MA、EMA、BOLL、VWAP;Params 为 nil 使用默认参数;Values 可覆盖内置计算,长度等于 candles,NaN 表示缺测。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/154-indicator-pane.md b/docs/components/154-indicator-pane.md new file mode 100644 index 0000000..0dbb6fb --- /dev/null +++ b/docs/components/154-indicator-pane.md @@ -0,0 +1,43 @@ +# 154 IndicatorPane + +## 用途 + +副图 MACD、RSI、KDJ、OBV;多副图堆叠与高度调整。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + panes := mujica.IndicatorPanes{Panes: []mujica.IndicatorPaneSpec{{Kind: mujica.IndicatorRSI, Params: []float64{2}}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IndicatorPane(c, candles, &window, &panes, mujica.IndicatorPaneOptions{Height: 200, Label: "BTC"}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +Panes 为 MACD、RSI、KDJ、OBV 配置;Sizes 保存高度比例;Height 默认每副图 140 DIP,最小高度覆盖标题、图例、读数和 40 DIP 绘图区;Label 为无障碍名称前缀;Status、Error、Empty 设置等尺寸占位。 + +## 状态 + +浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。 + +## 事件 + +IndicatorPaneResult.Removed 为移除索引,默认 -1;Resized 表示高度调整完成;Point、Pane 为活跃读数和副图索引,PointChanged 表示读数变化。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。主图与副图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/155-indicator-selector.md b/docs/components/155-indicator-selector.md new file mode 100644 index 0000000..f9fa444 --- /dev/null +++ b/docs/components/155-indicator-selector.md @@ -0,0 +1,37 @@ +# 155 IndicatorSelector + +## 用途 + +指标搜索、添加、移除、参数编辑。 + +## 最小示例 + +```go + var overlays []mujica.IndicatorOverlay + var panes mujica.IndicatorPanes + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IndicatorSelector(c, &overlays, &panes, mujica.IndicatorSelectorOptions{}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +overlays 与 panes 均由调用方持有;添加默认参数,可搜索、移除和编辑。Disabled 关闭面板并阻止编辑。 + +## 状态 + +浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。 + +## 事件 + +ChoiceResult.Changed() 表示选择变化。 + +## 键盘操作 + +Tab 聚焦按钮,Enter 打开;Tab 到搜索和参数字段;方向键步进参数;Escape 关闭。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/156-drawing-tools.md b/docs/components/156-drawing-tools.md new file mode 100644 index 0000000..bd3e095 --- /dev/null +++ b/docs/components/156-drawing-tools.md @@ -0,0 +1,43 @@ +# 156 DrawingTools + +## 用途 + +趋势线、水平线、斐波那契、矩形、文字标注;选中、移动、删除、撤销。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + drawings := mujica.DrawingTools{Tool: mujica.ToolTrendLine} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Drawings: &drawings}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +Items 保存时间和价格锚点;Tool 默认选择工具;Text 为文字标注内容;Locked 阻止编辑;Selected、Delete、Undo、Clear 管理画线。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +聚焦图表后方向键移动读数;Enter 在当前读数放置锚点,双点工具按两次 Enter;Alt+Enter 循环选择画线;点击放置锚点;Alt+方向键移动选中画线;Delete/Backspace 删除;Command/Ctrl+Z 撤销;Escape 取消。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 图形锚点使用已有 Candle 时间。 diff --git a/docs/components/157-drawing-toolbar.md b/docs/components/157-drawing-toolbar.md new file mode 100644 index 0000000..931b8a5 --- /dev/null +++ b/docs/components/157-drawing-toolbar.md @@ -0,0 +1,36 @@ +# 157 DrawingToolbar + +## 用途 + +画线工具栏;当前工具;锁定与清除;键盘快捷键提示。 + +## 最小示例 + +```go + var drawings mujica.DrawingTools + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DrawingToolbar(c, &drawings, mujica.DrawingToolbarOptions{Horizontal: true}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +传入 DrawingTools;Horizontal=true 横向,默认纵向;工具名称附快捷键提示。 + +## 状态 + +浅深主题沿用窗口设置;交互组件遵循祖先 Disabled;调用方持有选择及展开状态。 + +## 事件 + +ChoiceResult.Changed() 表示选择变化。 + +## 键盘操作 + +Tab 进入工具栏;方向键移动焦点,Enter 激活;Alt+1 至 Alt+6 选择工具。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/158-compare-symbol.md b/docs/components/158-compare-symbol.md new file mode 100644 index 0000000..8774375 --- /dev/null +++ b/docs/components/158-compare-symbol.md @@ -0,0 +1,44 @@ +# 158 CompareSymbol + +## 用途 + +叠加对比品种;百分比归一;序列颜色与移除。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + symbols := []mujica.CompareSymbol{{Name: "ETH", Closes: []float64{20, 21, 23}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CompareLegend(c, &symbols) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Compare: &symbols}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +Name 为稳定品种名称;Closes 与主图逐根对齐,正数或 NaN;CompareLegend 提供形状图例和移除。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 + +## 键盘操作 + +Tab 聚焦图表;方向键移动数据读数并朗读;Home/End 到首尾;Escape 清除读数。行情主图通过 +/- 缩放、Shift+左右方向键平移。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/components/159-price-alert-line.md b/docs/components/159-price-alert-line.md new file mode 100644 index 0000000..67175ea --- /dev/null +++ b/docs/components/159-price-alert-line.md @@ -0,0 +1,47 @@ +# 159 PriceAlertLine + +## 用途 + +价格预警线;拖动调整;触发回调;不自行通知。 + +## 最小示例 + +```go + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + alerts := []mujica.PriceAlertLine{{ID: "take-profit", Price: 105}} + triggered := "" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Alerts: &alerts, OnAlert: func(alert mujica.PriceAlertLine, last mujica.Candle) { + triggered = alert.ID + }}) + ui.Text(c, triggered) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +ID 为唯一非空字符串;Price 为有限价格;Label 可选。CandlestickOptions.Alerts 接入主图;OnAlert 接收预警和最新 Candle。 + +## 状态 + +图表通过 Frame 显示默认、加载、空数据、错误和重试;祖先 Disabled 阻止输入;浅深主题沿用窗口设置。 + +## 事件 + +ChartResult.Point 提供读数索引;Changed 表示读数变化;Retried 表示重试。 OnAlert 在最新收盘价格跨线时触发一次;拖动直接更新 Alerts。 + +## 键盘操作 + +聚焦图表后方向键查看价格;PageUp/PageDown 选择预警线;Alt+上下方向键按价格精度步进;拖动预警线调整价格。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 百分比对比模式展示归一后的品种曲线,预警使用价格模式。 diff --git a/docs/components/160-candle-countdown.md b/docs/components/160-candle-countdown.md new file mode 100644 index 0000000..9023ad9 --- /dev/null +++ b/docs/components/160-candle-countdown.md @@ -0,0 +1,35 @@ +# 160 CandleCountdown + +## 用途 + +当前 K 线收盘倒计时;随周期变化;隐藏时停止计时重绘。 + +## 最小示例 + +```go + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandleCountdown(c, "1h", mujica.CandleCountdownOptions{Location: time.UTC}) + } + ui.Render(view, 640, 400, 1) +``` + +## 参数 + +interval 为 Intervals 中的周期;Location 默认 UTC;Now 零值读取帧时钟,指定值冻结时间。 + +## 状态 + +随周期和时间更新;指定 Now 显示冻结读数;移出可见区域停止安排计时帧。 + +## 事件 + +返回 Element;时间由帧时钟或调用方指定。 + +## 键盘操作 + +展示型组件;周期由调用方选择控件调整。 + +## 限制 + +数据由调用方提供;更新数据时替换切片,缓存按切片身份失效。行情读取、交易及持久化由调用方负责。 diff --git a/docs/prd/ext-1.md b/docs/prd/ext-1.md deleted file mode 100644 index 4726b5c..0000000 --- a/docs/prd/ext-1.md +++ /dev/null @@ -1,232 +0,0 @@ -# MujicaUI PRD 扩展一:101—280 - -版本:1.1 -日期:2026-10-05 -来源:ELY GPUI 组件清单(`Ely-GPUI-Components/gpui-components.md`)中与本库路线相符的条目。 - -本扩展沿用 PRD 第 2、3、5、6、8、9、10 节的全部规则。每行一个公开组件;子部件、变体、尺寸不单独计数。图表、行情与媒体组件以 MyGo Painter 绘制,不建图表引擎、音视频解码或富文本编辑引擎:数据、帧与解码由调用方提供。 - -## 4.12 通用图表一 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 101 | LineChart | 多序列折线;数值与时间轴;缺值断开;悬停读数;序列显隐。 | -| 102 | AreaChart | 面积图;单序列与堆叠;基线可配置;悬停读数。 | -| 103 | BarChart | 纵横向柱状;分组与堆叠;负值;柱标签;键盘逐柱读数。 | -| 104 | PieChart | 饼图与环形;扇区标签;最小角合并为“其他”;中心总计。 | -| 105 | ScatterChart | 散点;点大小映射(气泡);分组颜色;框选。 | -| 106 | RadarChart | 多维雷达;多序列;刻度环;悬停读数。 | -| 107 | HeatmapChart | 二维矩阵热力;色阶图例;单元格读数;空值样式。 | -| 108 | CalendarHeatmap | 按日贡献图;年份切换;周起始可配置;色阶与读数。 | -| 109 | Histogram | 分箱计数;箱宽与箱数可配置;累计模式。 | -| 110 | BoxPlot | 四分位、须线、离群点;多组对比。 | -| 111 | WaterfallChart | 增减累计;小计与总计柱;正负语义色。 | -| 112 | FunnelChart | 漏斗阶段;转化率;阶段读数。 | -| 113 | BulletChart | 实际值、目标值、区间带;纵横向。 | -| 114 | Sparkline | 行内迷你折线、柱形与面积;末值标记;无坐标轴。 | -| 115 | Gauge | 半圆或整圆仪表;阈值分段;数值与单位;不能只靠颜色表达区间。 | -| 116 | Treemap | 层级矩形;点击下钻与返回;标签按面积省略。 | -| 117 | Sunburst | 层级环形;下钻;路径面包屑。 | -| 118 | SankeyChart | 节点与流量带;悬停高亮路径;节点排序稳定。 | -| 119 | NetworkGraph | 节点与边;调用方给定或简单力导布局;拖动节点;缩放平移。 | -| 120 | RealtimeChart | 实时追加数据;固定窗口滚动;暂停;隐藏时停止重绘。 | - -## 4.13 通用图表二与图表部件 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 121 | ChartLegend | 序列图例;点击显隐;键盘切换;横纵布局与溢出折叠。 | -| 122 | ChartTooltip | 跟随指针或焦点的读数浮层;多序列同点读数;边缘避让。 | -| 123 | ChartCrosshair | 十字光标;轴上读数标签;吸附最近数据点。 | -| 124 | ChartAxis | 数值、时间、类别轴;刻度自动与指定;格式化函数;对数轴。 | -| 125 | ChartGrid | 网格线;主次刻度;按设备像素对齐。 | -| 126 | ChartBrush | 区间选择与缩放;拖动、调整两端;键盘移动;回调区间。 | -| 127 | ChartAnnotation | 水平线、垂直线、区间带与文字标注;拖动可选。 | -| 128 | ChartExport | 以当前主题渲染图表为 PNG 字节;尺寸与缩放可配置;不写文件。 | -| 129 | ViolinPlot | 核密度轮廓;中位数与四分位;多组。 | -| 130 | ParallelCoordinates | 多维平行轴;轴上刷选;线高亮。 | -| 131 | ChordDiagram | 关系矩阵弦图;悬停高亮;组弧标签。 | -| 132 | SparkBar | 行内迷你柱;正负值;高亮末项。 | -| 133 | TrendIndicator | 趋势箭头与变化值;正负语义;不只靠颜色。 | -| 134 | MetricCard | 指标名、值、变化、迷你图插槽;加载与错误状态。 | -| 135 | ProgressChart | 多目标完成度环或条;目标线;百分比读数。 | -| 136 | StackedPercentBar | 百分比堆叠条;分段标签;悬停读数。 | -| 137 | DualAxisChart | 左右双轴组合(柱与线);各自刻度与格式。 | -| 138 | DensityPlot | 一维核密度曲线;带宽可配置;多组叠加。 | -| 139 | QuadrantChart | 四象限散点;象限标题与中线可配置。 | -| 140 | ChartEmptyState | 图表的无数据、加载、错误与重试占位;保持图表尺寸。 | - -## 4.14 行情图表与技术分析 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 141 | CandlestickChart | K 线;涨跌色可配置(红涨或绿涨);缩放平移;万根级只绘可见区。 | -| 142 | OHLCChart | 美国线;与 K 线共用数据接口。 | -| 143 | HeikinAshiChart | 平均 K 线;由调用方 OHLC 计算;与原 K 线可切换。 | -| 144 | LineQuoteChart | 分时图;昨收基线;均价线;盘前盘后分段。 | -| 145 | VolumeChart | 成交量柱;涨跌着色;与主图共享时间轴。 | -| 146 | DepthChart | 买卖深度累计面积;中间价;悬停读数。 | -| 147 | VolumeProfile | 价格分布成交量;价值区与控制点。 | -| 148 | MultiChartLayout | 1、2、4、6 宫格多图;独立品种与周期;布局切换不丢状态。 | -| 149 | ChartSync | 多图十字光标与时间范围同步;可逐图解除。 | -| 150 | TimeRangeSelector | 1D、5D、1M、3M、YTD、1Y、5Y、自定义;键盘导航。 | -| 151 | IntervalSelector | 1m 至 1M 周期;分组;收藏周期。 | -| 152 | ChartTypeSwitcher | K 线、美国线、折线、面积、平均 K 线切换。 | -| 153 | IndicatorOverlay | 主图叠加 MA、EMA、BOLL、VWAP;参数可配;计算由调用方或内置辅助函数。 | -| 154 | IndicatorPane | 副图 MACD、RSI、KDJ、OBV;多副图堆叠与高度调整。 | -| 155 | IndicatorSelector | 指标搜索、添加、移除、参数编辑。 | -| 156 | DrawingTools | 趋势线、水平线、斐波那契、矩形、文字标注;选中、移动、删除、撤销。 | -| 157 | DrawingToolbar | 画线工具栏;当前工具;锁定与清除;键盘快捷键提示。 | -| 158 | CompareSymbol | 叠加对比品种;百分比归一;序列颜色与移除。 | -| 159 | PriceAlertLine | 价格预警线;拖动调整;触发回调;不自行通知。 | -| 160 | CandleCountdown | 当前 K 线收盘倒计时;随周期变化;隐藏时停止计时重绘。 | - -## 4.15 报价、盘口与交易 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 161 | TickerTape | 滚动行情条;悬停与聚焦暂停;减少动画时静态分页。 | -| 162 | QuoteCard | 代码、名称、现价、涨跌、迷你图插槽。 | -| 163 | PriceText | 价格精度与千分位;涨跌色;变化闪烁一次(减少动画时无闪烁)。 | -| 164 | PriceChangeBadge | 涨跌额与幅度;箭头与符号;不只靠颜色。 | -| 165 | OrderBook | 买卖档位;深度条;价格聚合档位;点击价格回传。 | -| 166 | Level2Quotes | 多档买卖与委托量;逐档变化高亮。 | -| 167 | TimeAndSales | 逐笔成交;大单标记;虚拟化;暂停跟随。 | -| 168 | DepthLadder | 价格阶梯(DOM);买卖量列;当前价居中;点击下单回调。 | -| 169 | SpreadIndicator | 买卖价差与百分比;阈值提示。 | -| 170 | BidAskBar | 买卖力量对比条;数值标签。 | -| 171 | OrderEntry | 限价、市价、止损、条件单;数量、价格、有效期;校验与预估金额;提交防重入。 | -| 172 | QuickTradeButtons | 快速买卖;数量预设;危险操作确认可配置。 | -| 173 | PositionTable | 持仓;成本、市值、浮动盈亏;平仓操作回调;稳定行 ID。 | -| 174 | OrderTable | 委托;状态筛选;撤单回调;部分成交进度。 | -| 175 | TradeHistoryTable | 成交记录;时间范围筛选;导出回调。 | -| 176 | PnLDisplay | 已实现、未实现、当日盈亏;正负语义;隐私遮蔽切换。 | -| 177 | LeverageSlider | 杠杆档位;风险提示区间;确认后生效。 | -| 178 | MarginIndicator | 保证金率;警戒与强平线;不只靠颜色。 | -| 179 | RiskMeter | 风险等级刻度与说明文字。 | -| 180 | OrderConfirmDialog | 下单摘要与风险提示;默认聚焦取消;二次确认可配置。 | - -## 4.16 市场与资产 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 181 | OptionChain | 行权价居中的认购认沽表;到期日切换;价内高亮。 | -| 182 | GreeksTable | Delta、Gamma、Theta、Vega、Rho;精度与说明。 | -| 183 | PayoffDiagram | 期权组合到期损益;盈亏平衡点;当前价标记。 | -| 184 | Watchlist | 自选分组;拖动排序;实时刷新不丢选择;列可配置。 | -| 185 | SymbolSearch | 代码与名称搜索;市场筛选;最近搜索;异步结果由调用方提供。 | -| 186 | SymbolBadge | 代码、市场、类型标签;可点击。 | -| 187 | MarketHeatmap | 板块与个股热力;面积映射市值;涨跌色阶;下钻。 | -| 188 | Screener | 条件构建(字段、比较、值);结果表;保存条件回调。 | -| 189 | MarketOverview | 指数卡片组与涨跌家数;时间戳。 | -| 190 | IndexCard | 指数现价、涨跌、迷你图。 | -| 191 | MarketStatus | 开盘、收盘、盘前、盘后、休市;下一状态时间。 | -| 192 | TradingSessionClock | 多市场交易时段时钟;时区由调用方指定。 | -| 193 | EconomicCalendar | 经济事件;重要性;预期、前值、实际;国家筛选。 | -| 194 | EarningsCalendar | 财报日程;盘前盘后;预期与实际。 | -| 195 | NewsFeed | 新闻流;来源、时间、相关代码;已读状态;虚拟化。 | -| 196 | SentimentGauge | 情绪刻度(恐惧到贪婪);数值与文字。 | -| 197 | PortfolioSummary | 总资产、当日盈亏、累计收益;分账户。 | -| 198 | AssetAllocationChart | 资产配置环形与列表;目标与偏离。 | -| 199 | PerformanceChart | 收益曲线、基准对比、最大回撤区间。 | -| 200 | CurrencyConverter | 币种选择与互换;汇率由调用方提供;精度规则。 | - -## 4.17 AI 对话:消息 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 201 | ChatContainer | 消息区、输入区、侧栏插槽;新消息自动滚底,用户上翻时停止跟随。 | -| 202 | MessageList | 虚拟化消息列表;稳定消息 ID;新消息提示;按日期分隔。 | -| 203 | MessageBubble | 用户、助手、系统、错误四种;头像与名称插槽;宽度限制。 | -| 204 | MessageHeader | 模型名、时间、状态。 | -| 205 | StreamingText | 流式追加文本;平滑出字可关闭;流式光标;减少动画时直接显示。 | -| 206 | MarkdownView | Markdown 子集渲染:标题、段落、强调、列表、引用、链接、代码、表格、分隔线;流式增量渲染不重排已完成块。 | -| 207 | CodeBlock | 语言标签、行号、基础语法着色、复制、折叠、换行切换。 | -| 208 | TableBlock | 消息内表格;横向滚动;复制为 TSV。 | -| 209 | ImageGrid | 1 至 9 张图片网格;点击打开 ImageViewer;加载与失败。 | -| 210 | FileMessage | 文件名、类型图标、大小、打开与下载回调。 | -| 211 | AudioMessage | 语音条;波形;播放进度;时长;播放由调用方驱动。 | -| 212 | LinkPreviewCard | 标题、描述、站点、缩略图;元数据由调用方提供。 | -| 213 | QuoteReply | 引用原消息摘要;点击跳转原消息。 | -| 214 | MessageActions | 复制、重新生成、编辑、赞、踩、朗读、分享;按角色显示。 | -| 215 | MessageEditor | 编辑已发送消息;保存与取消;保存后产生新分支回调。 | -| 216 | BranchNavigator | 分支切换(< 2/3 >);键盘操作。 | -| 217 | ErrorMessage | 失败原因、重试;限流倒计时(RateLimit);不吞掉原错误文本。 | -| 218 | ThinkingBlock | 可折叠思考过程;思考中动画与耗时;默认折叠可配置。 | -| 219 | ScrollToBottomButton | 离开底部时出现;未读计数;点击回到底部。 | -| 220 | TypingIndicator | 对方正在输入三点动画;减少动画时显示静态文字。 | - -## 4.18 AI 对话:输入与引用 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 221 | PromptComposer | 多行输入;Enter 发送、Shift+Enter 换行可配置;输入法组合期间不发送;附件与工具插槽。 | -| 222 | AttachmentChip | 附件名、类型、大小、上传进度、移除;失败重试。 | -| 223 | PasteImagePreview | 粘贴或拖入图片的预览缩略图;移除。 | -| 224 | DragDropOverlay | 拖入文件时的全区域遮罩与提示;类型检查回调。 | -| 225 | VoiceInputButton | 录音开始与停止;录音时长;权限失败提示;录音由调用方实现。 | -| 226 | VoiceWaveform | 输入电平波形;静音提示。 | -| 227 | SlashCommandMenu | 输入“/”弹出命令;筛选;方向键选择;Tab 补全。 | -| 228 | ContextMentionMenu | 输入“@”选择文件、文档、网页等上下文;分组;异步结果。 | -| 229 | ContextChips | 已附加上下文列表;移除;溢出折叠。 | -| 230 | ModelSelector | 模型列表;能力标签与上下文长度;不可用项说明。 | -| 231 | ModeSelector | 对话、搜索、深度研究、Agent 模式切换。 | -| 232 | ToolToggleMenu | 联网、代码执行等工具开关;需授权项标记。 | -| 233 | ParameterPanel | 温度、Top-p、最大长度等参数;范围与默认值;重置。 | -| 234 | TokenCounter | 当前输入 token 数与上限;估算值由调用方提供。 | -| 235 | ContextWindowMeter | 上下文使用率条;分段(系统、历史、附件);阈值警告。 | -| 236 | SendButton | 发送与停止两态;禁用条件;快捷键提示。 | -| 237 | CitationBadge | 行内引用编号;悬停预览来源;点击定位来源。 | -| 238 | SourceCard | 来源标题、站点、摘要、时间;打开回调。 | -| 239 | SourcesPanel | 来源列表;按引用顺序;与正文引用联动高亮。 | -| 240 | SearchProgress | 搜索中关键词与已读来源数;阶段列表;完成后折叠。 | - -## 4.19 AI 对话:会话与引导 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 241 | ConversationList | 按今天、昨天、7 天内、更早分组;置顶;虚拟化。 | -| 242 | ConversationItem | 标题、时间、重命名、删除、置顶、导出回调;行内重命名。 | -| 243 | ConversationSearch | 标题与内容搜索;匹配高亮;异步结果。 | -| 244 | ProjectList | 项目与文件夹;会话归属;拖动移入。 | -| 245 | ProjectKnowledgePanel | 项目知识文件列表;添加、移除、容量提示。 | -| 246 | WelcomeScreen | 问候、能力卡片、起始提示插槽。 | -| 247 | SuggestionChips | 起始提示与追问建议;点击填入或直接发送可配置。 | -| 248 | PromptLibrary | 提示模板分类、搜索、预览、插入;变量占位符填写。 | -| 249 | CapabilityCards | 能力说明卡片;点击示例。 | -| 250 | FeedbackForm | 赞踩后的原因选项与文字;提交与取消。 | -| 251 | DateSeparator | 消息日期分隔;今天、昨天与日期格式本地化。 | -| 252 | ThinkingIndicator | 思考中状态动画与阶段文字;减少动画时静态。 | -| 253 | StopGeneratingButton | 停止生成;生成结束后隐藏;快捷键。 | -| 254 | SystemPromptEditor | 系统提示多行编辑;字数;恢复默认;未保存标记。 | -| 255 | CostEstimator | 输入、输出 token 与费用估算;单价由调用方提供。 | -| 256 | SharedConversationView | 只读会话视图;分享者与时间;复制链接回调。 | -| 257 | ConversationExport | 导出为 Markdown、JSON、纯文本;范围选择;内容由组件生成,写文件由调用方完成。 | -| 258 | RegenerateMenu | 重新生成;换模型重试;更长、更短等变体选项。 | -| 259 | ArtifactCard | 消息内产物卡片(代码、文档、网页);打开产物面板回调。 | -| 260 | ChatEmptyState | 新会话、无搜索结果、离线三种空状态。 | - -## 4.20 AI Agent 与工具调用 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 261 | ToolCallCard | 工具名、参数、结果、耗时、状态;参数与结果折叠;JSON 高亮。 | -| 262 | ToolCallGroup | 连续调用折叠为一组;总耗时与失败数。 | -| 263 | ToolApprovalDialog | 执行前审批;参数预览;允许、拒绝;默认聚焦拒绝。 | -| 264 | PermissionPrompt | 允许一次、始终允许、拒绝;作用范围说明。 | -| 265 | AgentStepList | 执行步骤时间线;当前步;步骤展开详情;失败步重试回调。 | -| 266 | AgentPlan | 计划清单;实时勾选;新增与跳过项;进度。 | -| 267 | AgentStatus | 规划中、执行中、等待、完成、失败;不只靠颜色;状态动画可关闭。 | -| 268 | AgentProgress | 总体进度与当前动作文字;不确定模式。 | -| 269 | SubAgentTree | 子 Agent 层级;各自状态;展开查看。 | -| 270 | FileChangeCard | 文件变更预览;增删统计;接受与拒绝;打开差异。 | -| 271 | MultiFileDiffReview | 多文件审阅;逐文件接受拒绝;全部接受;进度。 | -| 272 | CommandExecutionCard | 命令、工作目录、输出(ANSI)、退出码、耗时;中止回调。 | -| 273 | ScreenshotStream | Agent 画面帧序列查看;帧由调用方提供;暂停与逐帧。 | -| 274 | ArtifactPanel | 产物面板;代码、文档、网页预览插槽;复制与导出回调。 | -| 275 | ArtifactVersionSwitcher | 产物版本切换与差异入口。 | -| 276 | SandboxStatus | 沙箱运行状态、资源与剩余时间。 | -| 277 | MCPServerList | 服务器列表;连接状态;启停与重连回调;工具数。 | -| 278 | MemoryPanel | 记忆条目列表;搜索;编辑与删除。 | -| 279 | CheckpointList | 检查点;回滚确认;当前点标记。 | -| 280 | HumanInputRequest | Agent 请求用户输入;问题、选项与自由回答;超时提示。 | diff --git a/docs/prd/ext-2.md b/docs/prd/ext-2.md deleted file mode 100644 index c14aced..0000000 --- a/docs/prd/ext-2.md +++ /dev/null @@ -1,155 +0,0 @@ -# MujicaUI PRD 扩展二:281—400 - -版本:1.1 -日期:2026-10-05 -规则同扩展一。动画组件服从 PRD 3.4:状态过渡 120 毫秒、弹层 160 毫秒,不用弹跳与装饰性循环,减少动画模式直接切换或显示静态说明。 - -## 4.21 Agent 运维与动效 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 281 | InterruptButton | 中断运行中的 Agent;确认可配置;中断中状态。 | -| 282 | TokenUsageChart | 按会话或步骤的 token 用量柱线图;输入输出分色。 | -| 283 | CostBreakdown | 按模型、工具、步骤的费用分解表与合计。 | -| 284 | TraceViewer | 调用链树与时间轴;跨度耗时;选中查看属性。 | -| 285 | EvalResultTable | 评测用例、得分、通过与失败;差异查看回调。 | -| 286 | ToolRegistryPanel | 可用工具列表;参数模式预览;启停。 | -| 287 | DotsLoader | 三点加载;尺寸与说明;减少动画时静态。 | -| 288 | BarsLoader | 条形加载;同上。 | -| 289 | PulseLoader | 单点呼吸式加载;强度受限,不发光;减少动画时静态。 | -| 290 | OrbitLoader | 菱形环绕加载;与 Spinner 同一视觉语言。 | -| 291 | WaveLoader | 波形加载;同上。 | -| 292 | Shimmer | 占位扫光;仅用于加载中;减少动画时静态填充。 | -| 293 | CountUp | 数字从旧值过渡到新值;时长与精度;减少动画时直接显示。 | -| 294 | Typewriter | 逐字显示文本;速度;完成回调;跳过;减少动画时直接显示。 | -| 295 | ShimmerText | 处理中文字扫光(如“思考中”);减少动画时静态。 | -| 296 | Stagger | 子项依次进场;间隔可配;只在出现时执行一次。 | -| 297 | ReorderTransition | 列表重排时元素移动过渡;稳定键。 | -| 298 | LayoutTransition | 布局尺寸与位置变化过渡(FLIP)。 | -| 299 | Presence | 元素进出场过渡(淡入淡出、滑动、缩放);退出完成后移除。 | -| 300 | BlinkHighlight | 数值变化单次高亮;方向色;减少动画时改为静态标记。 | - -## 4.22 版本控制 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 301 | DiffViewer | 并排与统一视图;行号;增删高亮;折叠未变更区;大文件只构建可见行。 | -| 302 | InlineDiff | 行内词级差异高亮。 | -| 303 | ThreeWayMerge | 本地、基准、远端三栏与结果栏;逐块采纳。 | -| 304 | ConflictResolver | 冲突块列表;采纳当前、传入、两者;已解决计数。 | -| 305 | ChangesList | 暂存与未暂存分组;逐文件与全部暂存;状态标记。 | -| 306 | CommitInput | 提交标题与正文;标题长度提示;提交与修订;防重入。 | -| 307 | CommitGraph | 提交图谱泳道;分支与合并线;虚拟化。 | -| 308 | CommitList | 提交列表;作者、时间、哈希、消息;选择查看。 | -| 309 | BranchSelector | 本地与远端分支搜索选择;新建分支入口。 | -| 310 | BranchList | 分支列表;当前分支、上游、领先落后数;操作回调。 | -| 311 | TagList | 标签列表;注释;跳转提交。 | -| 312 | StashList | 贮藏列表;应用、弹出、删除回调。 | -| 313 | BlameView | 逐行作者与提交;同提交行合并;悬停详情。 | -| 314 | FileHistory | 单文件提交历史与差异入口。 | -| 315 | PullRequestCard | 标题、作者、状态、检查、评审人、差异统计。 | -| 316 | ReviewComment | 行级评审评论;回复;解决与重开。 | -| 317 | DiffStat | +增 −删 统计与比例条。 | -| 318 | GitStatusBadge | M、A、D、U、R、C 状态标记;不只靠颜色。 | -| 319 | CommandBlock | 命令分块(输入、输出、退出码、耗时);折叠;复制。 | -| 320 | AnsiText | ANSI 16 色、256 色、真彩与粗斜下划线渲染;未知序列原样保留并记录。 | - -## 4.23 终端、代码与调试 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 321 | Terminal | 终端网格:ANSI 光标移动与清屏、滚动缓冲、选择复制、输入回调;PTY 由调用方提供。 | -| 322 | TerminalTabs | 多终端标签;重命名;关闭确认。 | -| 323 | TerminalSearch | 滚动缓冲内搜索;匹配数与上下跳转。 | -| 324 | CommandHistory | 命令历史列表;搜索;重放回调。 | -| 325 | LogViewer | 日志级别过滤、跟随尾部、关键字高亮、虚拟化十万行。 | -| 326 | ProcessList | 进程表;CPU、内存排序;结束进程回调与确认。 | -| 327 | CodeViewer | 只读代码视图:行号、基础语法着色、折叠、当前行、选择复制;大文件虚拟化。 | -| 328 | Minimap | 代码缩略图;可见区框;拖动跳转。 | -| 329 | FindWidget | 查找与替换;大小写、整词、正则;匹配数。 | -| 330 | SearchPanel | 多文件搜索结果;按文件分组;匹配预览;包含与排除模式。 | -| 331 | ProblemsPanel | 错误、警告、信息列表;按文件分组;跳转回调。 | -| 332 | OutputPanel | 多通道输出;清空;跟随尾部。 | -| 333 | SymbolOutline | 符号大纲树;类型图标;筛选;跟随光标。 | -| 334 | CompletionMenu | 补全候选;类型图标与详情;方向键与 Tab 接受。 | -| 335 | DebugToolbar | 继续、单步、步入、步出、重启、停止;状态禁用规则。 | -| 336 | BreakpointList | 断点列表;启用开关;条件断点编辑。 | -| 337 | CallStack | 调用栈帧;当前帧;线程分组。 | -| 338 | VariablesPanel | 变量树形展开;值修改回调;变化高亮。 | -| 339 | HexViewer | 十六进制与 ASCII 对照;偏移列;选择同步;虚拟化。 | -| 340 | Flamegraph | 火焰图;点击放大子树;搜索高亮;耗时读数。 | - -## 4.24 媒体 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 341 | ImageCropper | 裁剪框;比例锁定;旋转 90 度;输出裁剪矩形。 | -| 342 | ImageAnnotator | 矩形、箭头、文字标注;选择与删除;导出标注数据。 | -| 343 | ImageThumbnail | 缩略图;比例裁切;选中与加载失败状态。 | -| 344 | Lightbox | 全窗图片浏览;左右切换;说明文字;Escape 关闭与焦点恢复。 | -| 345 | VideoPlayer | 帧由调用方提供的播放器外壳:播放、进度、倍速、字幕、全屏、画中画回调。 | -| 346 | VideoScrubber | 时间轴拖动;缓冲段;悬停时间与缩略图插槽。 | -| 347 | VideoThumbnailStrip | 缩略帧条;当前帧;点击跳转。 | -| 348 | AudioPlayer | 播放、暂停、进度、时长;播放由调用方驱动。 | -| 349 | AudioWaveform | 波形绘制;播放进度覆盖;区间选择。 | -| 350 | AudioSpectrum | 频谱柱;数据由调用方提供;隐藏时停止重绘。 | -| 351 | VolumeControl | 音量滑块与静音;方向键。 | -| 352 | PlaybackSpeedControl | 倍速选择 0.5×—2×。 | -| 353 | MediaControls | 上一首、播放、下一首、循环、随机;键盘。 | -| 354 | Playlist | 播放列表;当前项;拖动排序;移除。 | -| 355 | SubtitleEditor | 字幕条目时间与文本编辑;时间重叠校验。 | -| 356 | ScreenRecorderControls | 开始、暂停、停止、计时;录制由调用方实现。 | -| 357 | CameraPreview | 摄像头帧预览外壳;无信号状态;镜像切换。 | -| 358 | MicLevelMeter | 麦克风电平条;峰值保持。 | -| 359 | DeviceSelector | 麦克风、摄像头、扬声器选择;设备列表由调用方提供。 | -| 360 | ImageCompare | 前后对比滑块;键盘调整。 | - -## 4.25 项目与任务管理 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 361 | KanbanBoard | 列与卡片;拖动跨列移动与排序;列 WIP 上限;键盘移动卡片。 | -| 362 | TaskList | 任务列表;分组;完成过滤;虚拟化。 | -| 363 | TaskItem | 复选、标题、优先级、截止日期、负责人;行内编辑。 | -| 364 | TaskDetailPanel | 任务详情侧栏;字段编辑;活动记录插槽。 | -| 365 | SubtaskList | 子任务;进度;增删与排序。 | -| 366 | GanttChart | 任务条、依赖线、里程碑;拖动调整日期;今日线;时间缩放。 | -| 367 | Roadmap | 季度或月度路线图;泳道;条目拖动。 | -| 368 | SprintBoard | 迭代信息、剩余天数、看板。 | -| 369 | BurndownChart | 理想线与实际线;剩余工作量。 | -| 370 | PriorityIndicator | 紧急、高、中、低、无;图形与文字。 | -| 371 | StatusSelect | 工作流状态选择;状态分组。 | -| 372 | AssigneePicker | 成员搜索与多选;头像。 | -| 373 | DueDatePicker | 截止日期快捷项(今天、明天、下周)与日历;逾期标记。 | -| 374 | LabelManager | 标签创建、编辑颜色、删除、选择。 | -| 375 | IssueCard | 编号、标题、状态、优先级、负责人、标签。 | -| 376 | IssueIdBadge | ENG-123 样式编号;复制。 | -| 377 | MilestoneProgress | 里程碑完成度与日期。 | -| 378 | WorkloadView | 成员负载条;超载提示。 | -| 379 | TimeTracker | 计时开始停止;累计时长;手动修正。 | -| 380 | PomodoroTimer | 专注与休息周期;暂停;完成回调;隐藏时停止重绘。 | - -## 4.26 文件与协作 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 381 | FileExplorer | 文件夹树与文件列表组合;导航历史;选择。 | -| 382 | FileGrid | 文件网格视图;缩略图插槽;多选。 | -| 383 | FileIcon | 按扩展名与类型的图标;文件夹开合。 | -| 384 | FilePreview | 快速预览外壳:图片、文本、代码、不支持类型说明。 | -| 385 | PathBar | 路径分段;点击跳转;长路径中间折叠。 | -| 386 | RenameInline | 行内重命名;默认选中扩展名前部分;校验回调。 | -| 387 | FileOperationProgress | 复制、移动、删除进度;取消;冲突询问。 | -| 388 | TransferQueue | 上传下载队列;暂停、继续、重试、移除。 | -| 389 | StorageUsage | 存储占用分段条与明细。 | -| 390 | RecentFiles | 最近文件;时间分组;清除。 | -| 391 | ArchiveViewer | 压缩包条目树;大小与压缩率;条目由调用方提供。 | -| 392 | PresenceAvatars | 在线协作者头像;溢出计数;跟随入口。 | -| 393 | CommentThread | 评论与回复;编辑、删除、解决;提及插槽。 | -| 394 | Reactions | 表情回应与计数;添加回应选择器。 | -| 395 | ShareDialog | 分享对象、权限、链接复制回调。 | -| 396 | PermissionSelect | 可查看、可评论、可编辑。 | -| 397 | ActivityLog | 操作记录;按日分组;过滤。 | -| 398 | VersionHistory | 版本列表;预览与恢复回调;命名版本。 | -| 399 | LiveIndicator | 实时同步状态;断线提示。 | -| 400 | RemoteCursor | 远程光标与选区标记;姓名标签。 | diff --git a/docs/prd/ext-3.md b/docs/prd/ext-3.md deleted file mode 100644 index 003c5cd..0000000 --- a/docs/prd/ext-3.md +++ /dev/null @@ -1,130 +0,0 @@ -# MujicaUI PRD 扩展三:401—500 - -版本:1.1 -日期:2026-10-05 -规则同扩展一。账户类组件只负责界面与输入,不实现认证、不保存凭据;凭据只经调用方回调传出,不进日志、无障碍值或测试报告。 - -## 4.27 即时通讯与邮件 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 401 | ChannelList | 频道与私信;未读数、静音、置顶;分组折叠。 | -| 402 | ChatMessage | 连续消息合并、时间戳、编辑标记、回复与回应插槽。 | -| 403 | UnreadDivider | 未读分隔线与计数。 | -| 404 | ReadReceipt | 已发送、已送达、已读;多人已读头像。 | -| 405 | MemberList | 成员分组(在线、离线);角色。 | -| 406 | UserProfileCard | 头像、名称、状态、角色、操作插槽。 | -| 407 | OnlineStatus | 在线、离开、忙碌、离线;形状加文字。 | -| 408 | StatusSetter | 自定义状态文字与表情;清除时间。 | -| 409 | CallControls | 静音、摄像头、共享、挂断;状态与快捷键。 | -| 410 | VideoCallGrid | 参与者宫格;发言者高亮;帧由调用方提供。 | -| 411 | IncomingCallDialog | 来电接听与拒绝;默认聚焦拒绝可配置。 | -| 412 | EmojiPicker | 分类、搜索、最近使用、肤色。 | -| 413 | PinnedMessages | 置顶消息列表;跳转;取消置顶。 | -| 414 | ThreadPanel | 话题侧栏;原消息与回复。 | -| 415 | MailList | 未读粗体、星标、附件图标;多选;虚拟化。 | -| 416 | MailReader | 邮件头、正文(纯文本与安全子集)、附件。 | -| 417 | MailComposer | 收件人、抄送、密送、主题、正文、附件;草稿保存回调。 | -| 418 | RecipientInput | 收件人标签输入;地址校验;联系人建议。 | -| 419 | QuotedText | 引用文字折叠与展开。 | -| 420 | SnoozePicker | 稍后提醒快捷项与自定义时间。 | - -## 4.28 日历与时间 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 421 | CalendarMonthView | 月视图事件格;溢出“+N”;拖动改期。 | -| 422 | CalendarWeekView | 周视图时间网格;全天行;拖动创建与调整。 | -| 423 | CalendarDayView | 日视图;重叠事件并列。 | -| 424 | AgendaView | 日程列表;按日分组。 | -| 425 | YearView | 年视图;事件密度标记。 | -| 426 | EventChip | 事件块;颜色、时间、标题;截断。 | -| 427 | EventEditor | 标题、时间、地点、参与者、提醒、重复;校验。 | -| 428 | EventPopover | 事件摘要浮层;编辑、删除入口。 | -| 429 | CurrentTimeIndicator | 当前时间线;隐藏时停止更新。 | -| 430 | RecurrenceEditor | 每日、每周、每月、每年与自定义;结束条件。 | -| 431 | AvailabilityPicker | 空闲时段选择网格;多人重叠。 | -| 432 | TimezoneSelect | 时区搜索;偏移显示;常用时区。 | -| 433 | ReminderPicker | 提前提醒选项。 | -| 434 | AttendeeList | 参与者与回复状态。 | -| 435 | WeekPicker | 周选择;周起始可配置。 | -| 436 | DurationPicker | 时长输入(时分秒);范围。 | -| 437 | CronEditor | Cron 表达式编辑与可视化;校验;下次运行时间预览。 | -| 438 | RelativeTime | 相对时间文本;自动更新频率随时间远近;隐藏时停止。 | -| 439 | Countdown | 倒计时;结束回调;格式。 | -| 440 | Stopwatch | 秒表;计次;暂停与复位。 | - -## 4.29 仪表盘与开发工具 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 441 | DashboardGrid | 卡片网格;拖动与调整尺寸;布局保存回调。 | -| 442 | StatCard | 统计卡片;值、变化、迷你图;加载与错误。 | -| 443 | AlertList | 告警列表;级别、来源、时间;确认与静默回调。 | -| 444 | IncidentCard | 事件标题、状态、影响、时间线。 | -| 445 | UptimeBar | 按日可用性条;悬停详情。 | -| 446 | ServiceStatus | 服务状态列表;正常、降级、中断。 | -| 447 | LogStream | 实时日志流;暂停;级别过滤;虚拟化。 | -| 448 | TraceWaterfall | 请求瀑布图;跨度层级与耗时。 | -| 449 | ResourceGauge | CPU、内存、磁盘仪表;阈值。 | -| 450 | SystemMonitor | 多资源实时折线组合;采样数据由调用方提供。 | -| 451 | RefreshIntervalSelector | 刷新间隔选择;手动刷新。 | -| 452 | DashboardFilterBar | 时间范围、环境、标签过滤;清除。 | -| 453 | JsonViewer | JSON 树;展开折叠;路径复制;搜索。 | -| 454 | JsonEditor | JSON 文本编辑;格式化;解析错误定位。 | -| 455 | SchemaTree | 数据库、表、列、索引树。 | -| 456 | ApiRequestBuilder | 方法、URL、参数、请求头、正文;发送回调。 | -| 457 | ResponseViewer | 状态码、耗时、大小、正文(JSON 与文本)、响应头。 | -| 458 | KeyValueInput | 键值对行编辑;启用开关;增删。 | -| 459 | EnvironmentSelector | 环境切换;变量预览。 | -| 460 | RegexTester | 正则输入、测试文本、匹配高亮与分组;语法错误提示。 | - -## 4.30 设置、账户与引导 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 461 | SettingsLayout | 设置分区导航、分节、设置行(标签、说明、控件)。 | -| 462 | SettingsSearch | 设置项搜索;匹配高亮;跳转。 | -| 463 | ThemeSelector | 系统、浅色、深色预览卡选择。 | -| 464 | AccentColorPicker | 主色候选;对比度不足的候选标记。 | -| 465 | ShortcutRecorder | 录制快捷键;冲突提示;平台修饰键显示。 | -| 466 | KeyboardShortcutsList | 快捷键表;分组;搜索。 | -| 467 | LoginForm | 账号、密码、记住我;提交中与错误;凭据仅经回调传出。 | -| 468 | OAuthButtons | 第三方登录按钮组;名称由调用方提供,不使用商标图形。 | -| 469 | TwoFactorInput | 六位验证码;粘贴;重发倒计时。 | -| 470 | ProfileEditor | 头像、名称、简介;未保存标记。 | -| 471 | AccountSwitcher | 多账户切换;添加账户入口。 | -| 472 | WorkspaceSwitcher | 工作区搜索与切换。 | -| 473 | UserMenu | 用户头像菜单;状态、设置、退出。 | -| 474 | SessionList | 登录会话;设备与位置;注销其他会话回调。 | -| 475 | ApiKeyManager | 密钥列表;创建时只显示一次;撤销确认;明文不入日志。 | -| 476 | UsageQuota | 用量与配额条;重置时间。 | -| 477 | OnboardingWizard | 多步引导;进度;跳过;完成回调。 | -| 478 | Coachmark | 功能提示锚点;步骤导航;Escape 关闭。 | -| 479 | WhatsNewDialog | 更新内容列表;不再显示。 | -| 480 | FeedbackWidget | 反馈类型、文字、截图附件插槽;提交回调。 | - -## 4.31 通用补充 · 20 个 - -| 编号 | 组件 | 首版功能 | -| --- | --- | --- | -| 481 | SplitButton | 主操作与下拉菜单;两部分独立焦点。 | -| 482 | CopyButton | 复制并短暂显示已复制;屏幕阅读器播报。 | -| 483 | HoldToConfirm | 按住确认危险操作;进度;键盘按住空格;减少动画时显示计时文字。 | -| 484 | BulkActionBar | 多选后出现的批量操作条;选中数;清除选择。 | -| 485 | ScrubInput | 拖动标签调整数值;精度与范围;输入模式。 | -| 486 | CurrencyInput | 货币金额输入;币种符号;千分位;精度。 | -| 487 | MaskedInput | 格式掩码输入(电话、卡号等);输入法安全。 | -| 488 | MentionInput | @ 提及输入;候选;提及标记为整体删除。 | -| 489 | ChoiceChips | 单选或多选筛选芯片;键盘导航。 | -| 490 | Knob | 旋钮;拖动与方向键;刻度与数值。 | -| 491 | VerticalSlider | 纵向滑块;同 Slider 规则。 | -| 492 | ColorPalette | 预设色板;选择与键盘导航;自定义色入口。 | -| 493 | SignaturePad | 手写签名画板;清除;导出路径数据。 | -| 494 | Banner | 页面顶部横幅;信息、警告、错误;操作与关闭。 | -| 495 | UndoToast | 可撤销操作的消息;倒计时;撤销回调。 | -| 496 | Masonry | 瀑布流布局;列数随宽度;稳定键。 | -| 497 | ResizablePanelGroup | 多面板可调尺寸;最小值;折叠;尺寸保存回调。 | -| 498 | DockPanel | 可停靠面板区;拖动标签到边缘停靠;布局状态由调用方保存。 | -| 499 | QRCode | 文本生成二维码;纠错级别;尺寸;离线生成。 | -| 500 | ActivityFeed | 活动流;参与者、动作、对象、时间;按日分组。 | diff --git a/drawing_toolbar.go b/drawing_toolbar.go index 9341640..6dbaaf0 100644 --- a/drawing_toolbar.go +++ b/drawing_toolbar.go @@ -13,11 +13,11 @@ import ( // DrawingToolbarOptions configure a DrawingToolbar. type DrawingToolbarOptions struct { - // Horizontal lays the tools in a row; the default is a column beside the chart. + // Horizontal uses a row; the default uses a column. Horizontal bool } -// DrawingToolbar picks the drawing tool, locks, clears and undoes; Alt+1 to Alt+6 pick tools anywhere in the window. +// DrawingToolbar selects tools and manages lock, clear, and undo. func DrawingToolbar(c *ui.Context, d *DrawingTools, opts DrawingToolbarOptions) ChoiceResult { if d == nil { panic("mujica: DrawingToolbar needs drawings") @@ -38,6 +38,7 @@ func DrawingToolbar(c *ui.Context, d *DrawingTools, opts DrawingToolbarOptions) pick := func(t DrawingTool) { if !d.Locked && d.Tool != t && !bar.IsDisabled() { d.Tool, changed = t, true + d.pending, d.moving = nil, false slog.Debug("mujica: drawing_toolbar tool", "tool", t) } } @@ -85,12 +86,12 @@ func DrawingToolbar(c *ui.Context, d *DrawingTools, opts DrawingToolbarOptions) d.Tool = ToolSelect slog.Debug("mujica: drawing_toolbar lock", "locked", d.Locked) } - undo := iconAction(c, icons.Must("rotate-ccw"), msg(c, "drawing_toolbar.undo")).Tooltip(msg(c, "drawing_toolbar.undo")).Disabled(!d.CanUndo()) - if undo.Clicked() && d.Undo() { + undo := iconAction(c, icons.Must("rotate-ccw"), msg(c, "drawing_toolbar.undo")).Tooltip(msg(c, "drawing_toolbar.undo")).Disabled(d.Locked || !d.CanUndo()) + if undo.Clicked() && !bar.IsDisabled() && d.Undo() { changed = true } clear := iconAction(c, icons.Must("trash-2"), msg(c, "drawing_toolbar.clear")).Tooltip(msg(c, "drawing_toolbar.clear")).Disabled(d.Locked || len(d.Items) == 0) - if clear.Clicked() && d.Clear() { + if clear.Clicked() && !bar.IsDisabled() && d.Clear() { changed = true } }) @@ -107,7 +108,7 @@ func drawingToolGlyph(p *ui.Painter, r ui.Rect, t DrawingTool, col ui.Color) { path.MoveTo(cx-5, cy-7).LineTo(cx+5, cy+1).LineTo(cx, cy+1).LineTo(cx+3, cy+7).LineTo(cx+1, cy+8).LineTo(cx-2, cy+2).LineTo(cx-5, cy+5).Close() p.FillPath(&path, col) case ToolTrendLine: - p.Line(cx-s, cy+s, cx+s, cy-s, 1.5, col) + drawingStroke(p, cx-s, cy+s, cx+s, cy-s, 1.5, col) p.FillPath(internal.Diamond(cx-s, cy+s, 5), col) p.FillPath(internal.Diamond(cx+s, cy-s, 5), col) case ToolHorizontal: diff --git a/drawing_toolbar_test.go b/drawing_toolbar_test.go new file mode 100644 index 0000000..142cd0c --- /dev/null +++ b/drawing_toolbar_test.go @@ -0,0 +1,41 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" +) + +func TestDrawingToolbarShortcutsLockAndDisabled(t *testing.T) { + d := DrawingTools{} + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Disabled(disabled).Children(func() { DrawingToolbar(c, &d, DrawingToolbarOptions{Horizontal: true}) }) + }, 550, 90) + tt.Key(ui.Alt, ui.Key4) + if d.Tool != ToolFibonacci { + t.Fatalf("tool %v", d.Tool) + } + if err := tt.Click("Lock drawings"); err != nil { + t.Fatal(err) + } + tt.Key(ui.Alt, ui.Key3) + if !d.Locked || d.Tool != ToolSelect { + t.Fatal("locked tool changed") + } + if err := tt.Click("Unlock drawings"); err != nil { + t.Fatal(err) + } + disabled = true + tt.Frame() + tt.Key(ui.Alt, ui.Key2) + if d.Tool != ToolSelect { + t.Fatal("disabled tool changed") + } +} +func TestDrawingToolbarSnapshot(t *testing.T) { + d := DrawingTools{Tool: ToolTrendLine} + snapshot(t, "drawing_toolbar", 440, 90, func(c *ui.Context) { + ui.Row(c).Padding(12).Children(func() { DrawingToolbar(c, &d, DrawingToolbarOptions{Horizontal: true}) }) + }) +} diff --git a/drawing_tools.go b/drawing_tools.go index 7f30e8d..1a13889 100644 --- a/drawing_tools.go +++ b/drawing_tools.go @@ -10,7 +10,7 @@ import ( "github.com/egoist/mygo/ui" ) -// DrawingTool is what clicks on a price chart do. The zero value selects and moves drawings. +// DrawingTool selects annotation creation or selection behavior. type DrawingTool int const ( @@ -22,7 +22,7 @@ const ( ToolText ) -// DrawingPoint anchors a drawing at a candle time and a price, so it survives panning and new data. +// DrawingPoint anchors annotations to candle time and price. type DrawingPoint struct { Time time.Time Price float64 @@ -35,7 +35,7 @@ type Drawing struct { Text string } -// DrawingTools is the caller-held set of drawings, the current tool and an undo history. +// DrawingTools holds annotations, the current tool, and undo history. type DrawingTools struct { Items []Drawing Tool DrawingTool @@ -50,7 +50,7 @@ type DrawingTools struct { moving bool } -// fibLevels are the retracement ratios drawn between a Fibonacci's two points. +// fibLevels lists Fibonacci retracement ratios. var fibLevels = []float64{0, 0.236, 0.382, 0.5, 0.618, 0.786, 1} // Selected returns the selected drawing's index, or -1. @@ -63,10 +63,10 @@ func (d *DrawingTools) save() { d.undo = append(d.undo, slices.Clone(d.Items)) } -// Undo restores the drawings before the last change and reports whether there was one. +// Undo restores the preceding annotations unless locked. func (d *DrawingTools) Undo() bool { n := len(d.undo) - if n == 0 { + if d.Locked || n == 0 { return false } d.Items, d.undo = d.undo[n-1], d.undo[:n-1] @@ -103,12 +103,12 @@ func (t DrawingTool) twoPoint() bool { return t == ToolTrendLine || t == ToolFibonacci || t == ToolRectangle } -// point turns a plot position into a drawing point on the nearest candle. +// point maps plot coordinates onto the nearest candle. func (d *DrawingTools) point(m *mktCore, g mktGeom, x, y float32) DrawingPoint { return DrawingPoint{Time: m.candles[g.index(m, x)].Time, Price: m.priceAt(g.ys.Invert(y))} } -// priceAt turns an axis value back into a price, rounded to the decimals. +// priceAt resolves an axis value into a rounded price. func (m *mktCore) priceAt(v float64) float64 { if m.base != 0 { v = m.base * (1 + v/100) @@ -123,11 +123,19 @@ func (d *DrawingTools) xy(m *mktCore, g mktGeom, pt DrawingPoint) (float32, floa return g.x(m, i), g.ys.Map(m.val(pt.Price)) } -// input places, selects, drags, nudges and deletes drawings; it reports taking the pointer. +// input places, selects, moves, and deletes chart annotations. func (d *DrawingTools) input(m *mktCore, plot *ui.Element, g mktGeom, x, y float32, over, held bool, dx, dy float32) bool { if d.Tool < ToolSelect || d.Tool > ToolText { panic(fmt.Sprintf("mujica: unknown DrawingTool %d", d.Tool)) } + if d.pending != nil && d.pending.Tool != d.Tool { + d.pending, d.moving = nil, false + } + if plot.Shortcut(ui.Alt, ui.KeyEnter) && !d.Locked && len(d.Items) > 0 { + d.sel = d.sel%len(d.Items) + 1 + m.st.alertFocus = -1 + m.c.Announce(fmt.Sprintf(msg(m.c, "drawing_tools.selected"), d.sel)) + } if plot.Shortcut(ui.Cmd, ui.KeyZ) { d.Undo() } @@ -155,22 +163,7 @@ func (d *DrawingTools) input(m *mktCore, plot *ui.Element, g mktGeom, x, y float } if plot.Clicked() && d.Tool != ToolSelect { pt := d.point(m, g, x, y) - switch { - case d.Tool.twoPoint() && d.pending == nil: - d.pending = &Drawing{Tool: d.Tool, A: pt, B: pt} - default: - item := Drawing{Tool: d.Tool, A: pt, B: pt, Text: d.Text} - if d.pending != nil { - item.A = d.pending.A - } - if d.Tool == ToolText && item.Text == "" { - item.Text = msg(m.c, "drawing_tools.note") - } - d.save() - d.Items = append(slices.Clone(d.Items), item) - d.sel, d.pending, d.Tool = len(d.Items), nil, ToolSelect - slog.Debug("mujica: drawing_tools add", "tool", item.Tool) - } + d.place(m, pt) return true } if d.pending != nil { @@ -203,7 +196,7 @@ func (d *DrawingTools) input(m *mktCore, plot *ui.Element, g mktGeom, x, y float return true } -// nudge moves item i by candles and price, keeping it on the chart's candles. +// nudge moves annotations by candles and price. func (d *DrawingTools) nudge(m *mktCore, i, candles int, price float64) { it := &d.Items[i] move := func(pt *DrawingPoint) { @@ -214,7 +207,7 @@ func (d *DrawingTools) nudge(m *mktCore, i, candles int, price float64) { move(&it.B) } -// hit returns the drawing within 6 DIPs of (x, y), newest first, or -1. +// hit finds the newest annotation within 6 DIPs. func (d *DrawingTools) hit(m *mktCore, g mktGeom, x, y float32) int { for i := len(d.Items) - 1; i >= 0; i-- { it := d.Items[i] @@ -239,7 +232,7 @@ func (d *DrawingTools) hit(m *mktCore, g mktGeom, x, y float32) int { return -1 } -// drawingSegmentDist is the distance from (x, y) to the segment a-b. +// drawingSegmentDist measures distance to a line segment. func drawingSegmentDist(x, y, ax, ay, bx, by float32) float64 { vx, vy := float64(bx-ax), float64(by-ay) t := 0.0 @@ -248,3 +241,22 @@ func drawingSegmentDist(x, y, ax, ay, bx, by float32) float64 { } return math.Hypot(float64(x-ax)-t*vx, float64(y-ay)-t*vy) } + +func (d *DrawingTools) place(m *mktCore, pt DrawingPoint) { + if d.Tool.twoPoint() && d.pending == nil { + d.pending = &Drawing{Tool: d.Tool, A: pt, B: pt} + } else { + item := Drawing{Tool: d.Tool, A: pt, B: pt, Text: d.Text} + if d.pending != nil { + item.A = d.pending.A + } + if d.Tool == ToolText && item.Text == "" { + item.Text = msg(m.c, "drawing_tools.note") + } + d.save() + d.Items = append(slices.Clone(d.Items), item) + d.sel, d.pending, d.Tool = len(d.Items), nil, ToolSelect + slog.Debug("mujica: drawing_tools add", "tool", item.Tool) + } + m.c.Invalidate() +} diff --git a/drawing_tools_paint.go b/drawing_tools_paint.go index 7ee541e..fc894b5 100644 --- a/drawing_tools_paint.go +++ b/drawing_tools_paint.go @@ -8,7 +8,7 @@ import ( "mujicaui/internal" ) -// paint draws every drawing, the one being placed, and the selected one's handles. +// paint draws annotations, previews, and selected handles. func (d *DrawingTools) paint(p *ui.Painter, g mktGeom, m *mktCore, size float32) { k := m.k items := d.Items @@ -27,7 +27,7 @@ func (d *DrawingTools) paint(p *ui.Painter, g mktGeom, m *mktCore, size float32) ax, ay, bx, by = internal.Snap(p, ax), internal.Snap(p, ay), internal.Snap(p, bx), internal.Snap(p, by) switch it.Tool { case ToolTrendLine: - p.Line(ax, ay, bx, by, width, col) + drawingStroke(p, ax, ay, bx, by, width, col) case ToolHorizontal: p.Line(g.plot.X, ay, g.plot.X+g.plot.W, ay, width, col) chartText(p, priceUngrouped(it.A.Price, m.dec), g.plot.X+4, ay-2, size, col, 0, 1) @@ -42,7 +42,7 @@ func (d *DrawingTools) paint(p *ui.Painter, g mktGeom, m *mktCore, size float32) p.Line(min(ax, bx), y, max(ax, bx), y, 1, col) chartText(p, strconv.FormatFloat(lv, 'f', -1, 64)+" · "+priceUngrouped(price, m.dec), max(ax, bx)+4, y, size, col, 0, 0.5) } - p.Line(ax, ay, bx, by, 1, col.Alpha(0.5)) + drawingStroke(p, ax, ay, bx, by, 1, col.Alpha(0.5)) case ToolText: w, h := chartText(p, it.Text, ax, ay, size, k.Text, 0.5, 0.5) p.Stroke(ui.Rect{X: ax - w/2 - 4, Y: ay - h/2 - 2, W: w + 8, H: h + 4}, col, 2, width/1.5) @@ -55,3 +55,9 @@ func (d *DrawingTools) paint(p *ui.Painter, g mktGeom, m *mktCore, size float32) } } } + +func drawingStroke(p *ui.Painter, ax, ay, bx, by, width float32, col ui.Color) { + var path ui.Path + path.MoveTo(internal.Snap(p, ax), internal.Snap(p, ay)).LineTo(internal.Snap(p, bx), internal.Snap(p, by)) + p.StrokePath(&path, width, col) +} diff --git a/drawing_tools_test.go b/drawing_tools_test.go new file mode 100644 index 0000000..13e9902 --- /dev/null +++ b/drawing_tools_test.go @@ -0,0 +1,101 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "mujicaui/internal" + "mujicaui/theme" + "testing" +) + +func TestDrawingToolsPlaceMoveDeleteUndo(t *testing.T) { + cs := sampleCandles(60) + v := MarketView{Count: 60} + d := DrawingTools{Tool: ToolHorizontal} + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Drawing"}, Drawings: &d}) + }) + }, 640, 340) + tt.ClickAt(300, 100) + if len(d.Items) != 1 || d.Items[0].Tool != ToolHorizontal || d.Selected() != 0 { + t.Fatalf("drawings %+v", d) + } + before := d.Items[0].A.Price + tt.Key(ui.Alt, ui.KeyUp) + if d.Items[0].A.Price <= before { + t.Fatal("keyboard move failed") + } + tt.Key(0, ui.KeyDelete) + if len(d.Items) != 0 { + t.Fatal("delete failed") + } + tt.Key(ui.Cmd, ui.KeyZ) + if len(d.Items) != 1 { + t.Fatal("undo failed") + } + disabled = true + tt.Frame() + tt.Key(0, ui.KeyDelete) + tt.ClickAt(200, 150) + if len(d.Items) != 1 { + t.Fatal("disabled drawing changed") + } +} +func TestDrawingToolsSnapshot(t *testing.T) { + cs := sampleCandles(60) + v := MarketView{Count: 60} + d := DrawingTools{Items: []Drawing{{Tool: ToolTrendLine, A: DrawingPoint{cs[10].Time, 98}, B: DrawingPoint{cs[45].Time, 105}}, {Tool: ToolHorizontal, A: DrawingPoint{cs[20].Time, 100}}}, sel: 1} + snapshot(t, "drawing_tools", 640, 320, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Drawings", Height: 260}, Drawings: &d}) + }) + }) +} + +func TestDrawingToolsTrendLinePaint(t *testing.T) { + cs := sampleCandles(2) + img := ui.Render(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + m := &mktCore{c: c, k: Tokens(c), candles: cs, from: 0, to: 2} + g := mktGeom{plot: ui.Rect{W: 200, H: 200}, band: internal.Band{N: 2, R0: 0, R1: 200}, ys: internal.Linear{D0: 0, D1: 10, R0: 180, R1: 20}} + d := DrawingTools{Items: []Drawing{{Tool: ToolTrendLine, A: DrawingPoint{cs[0].Time, 0}, B: DrawingPoint{cs[1].Time, 10}}}} + ui.Box(c).Size(200, 200).Draw(func(p *ui.Painter, _ ui.Rect) { d.paint(p, g, m, 12) }) + }, 200, 200, 1) + bg := theme.Light().Background + pixel := img.RGBAAt(100, 100) + if int(bg.R)-int(pixel.R) < 40 || int(bg.G)-int(pixel.G) < 20 { + t.Fatalf("diagonal midpoint lacks the stroke: %v", pixel) + } +} + +func TestDrawingToolsAllTools(t *testing.T) { + for _, tool := range []DrawingTool{ToolTrendLine, ToolHorizontal, ToolFibonacci, ToolRectangle, ToolText} { + t.Run(string(rune('0'+tool)), func(t *testing.T) { + cs := sampleCandles(30) + v := MarketView{Count: 30} + d := DrawingTools{Tool: tool, Text: "Note"} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Drawing"}, Drawings: &d}) + }, 640, 340) + tt.ClickAt(200, 120) + if tool.twoPoint() { + if d.pending == nil || len(d.Items) != 0 { + t.Fatal("missing first anchor") + } + tt.ClickAt(400, 160) + } + if len(d.Items) != 1 || d.Items[0].Tool != tool || d.Selected() != 0 { + t.Fatalf("created %+v", d.Items) + } + if tool == ToolText && d.Items[0].Text != "Note" { + t.Fatal("annotation text lost") + } + if !d.Clear() || len(d.Items) != 0 || !d.Undo() || len(d.Items) != 1 { + t.Fatal("clear or undo failed") + } + }) + } +} diff --git a/example_mktchart_test.go b/example_mktchart_test.go new file mode 100644 index 0000000..4ef3be8 --- /dev/null +++ b/example_mktchart_test.go @@ -0,0 +1,265 @@ +package mujica_test + +import ( + "github.com/egoist/mygo/ui" + mujica "mujicaui" + "time" +) + +func ExampleCandlestickChart() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleOHLCChart() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.OHLCChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleHeikinAshiChart() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + raw := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.HeikinAshiChart(c, candles, &window, &raw, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleLineQuoteChart() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + points := []mujica.QuotePoint{{Time: at, Price: 101, Volume: 100}, {Time: at.Add(time.Hour), Price: 103, Volume: 200}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LineQuoteChart(c, points, mujica.LineQuoteChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, PrevClose: 100, RegularOpen: at.Add(30 * time.Minute), RegularClose: at.Add(45 * time.Minute)}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleVolumeChart() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeChart(c, candles, &window, mujica.VolumeChartOptions{Frame: mujica.ChartFrame{Label: "Volume"}}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleDepthChart() { + bids := []mujica.DepthLevel{{Price: 99, Size: 10}, {Price: 98, Size: 20}} + asks := []mujica.DepthLevel{{Price: 101, Size: 15}, {Price: 102, Size: 25}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DepthChart(c, bids, asks, mujica.DepthChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleVolumeProfile() { + bins := []mujica.ProfileBin{{Low: 99, High: 100, Volume: 30}, {Low: 100, High: 101, Volume: 80}, {Low: 101, High: 102, Volume: 40}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeProfile(c, bins, mujica.VolumeProfileOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleMultiChartLayout() { + state := mujica.MultiChartState{Count: 2} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MultiChartLayout(c, &state, mujica.MultiChartLayoutOptions{Height: 200}, func(i int, cell *mujica.ChartCell) { + mujica.TextInput(c, &cell.Symbol, mujica.TextInputOptions{Label: "Symbol"}) + }) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleChartSync() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + var sync mujica.ChartSync + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + sync.Link(c, "price") + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Sync: &sync, SyncID: "price"}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleTimeRangeSelector() { + value := mujica.MarketRange{Preset: mujica.Range1M} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TimeRangeSelector(c, &value, mujica.TimeRangeSelectorOptions{}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleIntervalSelector() { + value := mujica.Interval("1h") + favorites := []mujica.Interval{"1m", "1h", "1D"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IntervalSelector(c, &value, &favorites, mujica.IntervalSelectorOptions{}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleChartTypeSwitcher() { + value := mujica.ChartCandles + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChartTypeSwitcher(c, &value, mujica.ChartTypeSwitcherOptions{}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleIndicatorOverlay() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + overlays := []mujica.IndicatorOverlay{{Kind: mujica.IndicatorMA, Params: []float64{2}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Overlays: overlays}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleIndicatorPane() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + panes := mujica.IndicatorPanes{Panes: []mujica.IndicatorPaneSpec{{Kind: mujica.IndicatorRSI, Params: []float64{2}}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IndicatorPane(c, candles, &window, &panes, mujica.IndicatorPaneOptions{Height: 200, Label: "BTC"}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleIndicatorSelector() { + var overlays []mujica.IndicatorOverlay + var panes mujica.IndicatorPanes + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IndicatorSelector(c, &overlays, &panes, mujica.IndicatorSelectorOptions{}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleDrawingTools() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + drawings := mujica.DrawingTools{Tool: mujica.ToolTrendLine} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Drawings: &drawings}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleDrawingToolbar() { + var drawings mujica.DrawingTools + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DrawingToolbar(c, &drawings, mujica.DrawingToolbarOptions{Horizontal: true}) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleCompareSymbol() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + symbols := []mujica.CompareSymbol{{Name: "ETH", Closes: []float64{20, 21, 23}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CompareLegend(c, &symbols) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Compare: &symbols}) + } + ui.Render(view, 640, 400, 1) +} + +func ExamplePriceAlertLine() { + at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + alerts := []mujica.PriceAlertLine{{ID: "take-profit", Price: 105}} + triggered := "" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Alerts: &alerts, OnAlert: func(alert mujica.PriceAlertLine, last mujica.Candle) { + triggered = alert.ID + }}) + ui.Text(c, triggered) + } + ui.Render(view, 640, 400, 1) +} + +func ExampleCandleCountdown() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandleCountdown(c, "1h", mujica.CandleCountdownOptions{Location: time.UTC}) + } + ui.Render(view, 640, 400, 1) +} diff --git a/examples/gallery/pages_mktchart.go b/examples/gallery/pages_mktchart.go index 0f60886..e495179 100644 --- a/examples/gallery/pages_mktchart.go +++ b/examples/gallery/pages_mktchart.go @@ -1,3 +1,28 @@ package main -var mktchartPages = []page{} +import "github.com/egoist/mygo/ui" + +var mktchartPages = []page{ + {ID: "141", Name: "CandlestickChart", ZhName: "K 线", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "141") }, Code: mktCode141, Keys: mktKeys}, + {ID: "142", Name: "OHLCChart", ZhName: "美国线", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "142") }, Code: mktCode142, Keys: mktKeys}, + {ID: "143", Name: "HeikinAshiChart", ZhName: "平均 K 线", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "143") }, Code: mktCode143, Keys: mktKeys}, + {ID: "144", Name: "LineQuoteChart", ZhName: "分时图", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "144") }, Code: mktCode144, Keys: mktKeys}, + {ID: "145", Name: "VolumeChart", ZhName: "成交量", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "145") }, Code: mktCode145, Keys: mktKeys}, + {ID: "146", Name: "DepthChart", ZhName: "深度图", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "146") }, Code: mktCode146, Keys: mktKeys}, + {ID: "147", Name: "VolumeProfile", ZhName: "成交量分布", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "147") }, Code: mktCode147, Keys: mktKeys}, + {ID: "148", Name: "MultiChartLayout", ZhName: "多图布局", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "148") }, Code: mktCode148, Keys: mktKeys}, + {ID: "149", Name: "ChartSync", ZhName: "图表同步", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "149") }, Code: mktCode149, Keys: mktKeys}, + {ID: "150", Name: "TimeRangeSelector", ZhName: "时间范围", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "150") }, Code: mktCode150, Keys: mktKeys}, + {ID: "151", Name: "IntervalSelector", ZhName: "周期选择", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "151") }, Code: mktCode151, Keys: mktKeys}, + {ID: "152", Name: "ChartTypeSwitcher", ZhName: "图表类型", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "152") }, Code: mktCode152, Keys: mktKeys}, + {ID: "153", Name: "IndicatorOverlay", ZhName: "主图指标", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "153") }, Code: mktCode153, Keys: mktKeys}, + {ID: "154", Name: "IndicatorPane", ZhName: "指标副图", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "154") }, Code: mktCode154, Keys: mktKeys}, + {ID: "155", Name: "IndicatorSelector", ZhName: "指标选择", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "155") }, Code: mktCode155, Keys: mktKeys}, + {ID: "156", Name: "DrawingTools", ZhName: "画线", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "156") }, Code: mktCode156, Keys: mktKeys}, + {ID: "157", Name: "DrawingToolbar", ZhName: "画线工具栏", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "157") }, Code: mktCode157, Keys: mktKeys}, + {ID: "158", Name: "CompareSymbol", ZhName: "品种对比", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "158") }, Code: mktCode158, Keys: mktKeys}, + {ID: "159", Name: "PriceAlertLine", ZhName: "价格预警", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "159") }, Code: mktCode159, Keys: mktKeys}, + {ID: "160", Name: "CandleCountdown", ZhName: "K 线倒计时", Category: "Market Charts", Demo: func(c *ui.Context, log func(string)) { mktDemo(c, log, "160") }, Code: mktCode160, Keys: mktKeys}, +} + +const mktKeys = "Tab focuses controls and charts; arrows move focus or chart readouts; Space selects; Home/End jump; +/- zoom; Shift+arrows pan; Alt+1–6 pick drawing tools; Delete removes; Ctrl/Command+Z undoes; Escape closes panels." diff --git a/examples/gallery/pages_mktchart_code.go b/examples/gallery/pages_mktchart_code.go new file mode 100644 index 0000000..c81078d --- /dev/null +++ b/examples/gallery/pages_mktchart_code.go @@ -0,0 +1,219 @@ +package main + +const mktCode141 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode142 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.OHLCChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode143 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + raw := false + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.HeikinAshiChart(c, candles, &window, &raw, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode144 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + points := []mujica.QuotePoint{{Time: at, Price: 101, Volume: 100}, {Time: at.Add(time.Hour), Price: 103, Volume: 200}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.LineQuoteChart(c, points, mujica.LineQuoteChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, PrevClose: 100, RegularOpen: at.Add(30 * time.Minute), RegularClose: at.Add(45 * time.Minute)}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode145 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeChart(c, candles, &window, mujica.VolumeChartOptions{Frame: mujica.ChartFrame{Label: "Volume"}}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode146 = ` bids := []mujica.DepthLevel{{Price: 99, Size: 10}, {Price: 98, Size: 20}} + asks := []mujica.DepthLevel{{Price: 101, Size: 15}, {Price: 102, Size: 25}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DepthChart(c, bids, asks, mujica.DepthChartOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode147 = ` bins := []mujica.ProfileBin{{Low: 99, High: 100, Volume: 30}, {Low: 100, High: 101, Volume: 80}, {Low: 101, High: 102, Volume: 40}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.VolumeProfile(c, bins, mujica.VolumeProfileOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode148 = ` state := mujica.MultiChartState{Count: 2} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.MultiChartLayout(c, &state, mujica.MultiChartLayoutOptions{Height: 200}, func(i int, cell *mujica.ChartCell) { + mujica.TextInput(c, &cell.Symbol, mujica.TextInputOptions{Label: "Symbol"}) + }) + } + ui.Render(view, 640, 400, 1)` + +const mktCode149 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + var sync mujica.ChartSync + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + sync.Link(c, "price") + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Sync: &sync, SyncID: "price"}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode150 = ` value := mujica.MarketRange{Preset: mujica.Range1M} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TimeRangeSelector(c, &value, mujica.TimeRangeSelectorOptions{}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode151 = ` value := mujica.Interval("1h") + favorites := []mujica.Interval{"1m", "1h", "1D"} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IntervalSelector(c, &value, &favorites, mujica.IntervalSelectorOptions{}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode152 = ` value := mujica.ChartCandles + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChartTypeSwitcher(c, &value, mujica.ChartTypeSwitcherOptions{}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode153 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + overlays := []mujica.IndicatorOverlay{{Kind: mujica.IndicatorMA, Params: []float64{2}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Overlays: overlays}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode154 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + panes := mujica.IndicatorPanes{Panes: []mujica.IndicatorPaneSpec{{Kind: mujica.IndicatorRSI, Params: []float64{2}}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IndicatorPane(c, candles, &window, &panes, mujica.IndicatorPaneOptions{Height: 200, Label: "BTC"}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode155 = ` var overlays []mujica.IndicatorOverlay + var panes mujica.IndicatorPanes + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IndicatorSelector(c, &overlays, &panes, mujica.IndicatorSelectorOptions{}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode156 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + drawings := mujica.DrawingTools{Tool: mujica.ToolTrendLine} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Drawings: &drawings}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode157 = ` var drawings mujica.DrawingTools + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DrawingToolbar(c, &drawings, mujica.DrawingToolbarOptions{Horizontal: true}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode158 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + symbols := []mujica.CompareSymbol{{Name: "ETH", Closes: []float64{20, 21, 23}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CompareLegend(c, &symbols) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Compare: &symbols}) + } + ui.Render(view, 640, 400, 1)` + +const mktCode159 = ` at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + candles := []mujica.Candle{ + {Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200}, + {Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600}, + {Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900}, + } + var window mujica.MarketView + alerts := []mujica.PriceAlertLine{{ID: "take-profit", Price: 105}} + triggered := "" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandlestickChart(c, candles, &window, mujica.CandlestickOptions{Frame: mujica.ChartFrame{Label: "BTC", Height: 200}, Alerts: &alerts, OnAlert: func(alert mujica.PriceAlertLine, last mujica.Candle) { + triggered = alert.ID + }}) + ui.Text(c, triggered) + } + ui.Render(view, 640, 400, 1)` + +const mktCode160 = ` view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CandleCountdown(c, "1h", mujica.CandleCountdownOptions{Location: time.UTC}) + } + ui.Render(view, 640, 400, 1)` diff --git a/examples/gallery/pages_mktchart_demo.go b/examples/gallery/pages_mktchart_demo.go new file mode 100644 index 0000000..f0fc07b --- /dev/null +++ b/examples/gallery/pages_mktchart_demo.go @@ -0,0 +1,214 @@ +package main + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + mujica "mujicaui" +) + +func mktDemo(c *ui.Context, log func(string), id string) { + s := ui.Local(c.Root(), id, mktInitial) + ui.Column(c).Gap(12).FillWidth().Children(func() { + frame := mktControls(c, s, log) + candles, points, bids, asks, bins := s.candles, s.points, s.bids, s.asks, s.bins + if s.status == "Empty" { + candles = nil + points = nil + bids = nil + asks = nil + bins = nil + } + ui.Column(c).Disabled(s.disabled).Gap(12).FillWidth().Children(func() { + opts := mujica.CandlestickOptions{Frame: frame, Type: s.chartType} + chart := func() { mktLog(log, id, mujica.CandlestickChart(c, candles, &s.window, opts)) } + switch id { + case "141": + if mujica.ChartTypeSwitcher(c, &s.chartType, mujica.ChartTypeSwitcherOptions{}).Changed() { + log(fmt.Sprintf("Chart type: %d", s.chartType)) + } + opts.Type = s.chartType + chart() + case "142": + mktLog(log, id, mujica.OHLCChart(c, candles, &s.window, opts)) + case "143": + before := s.raw + mktLog(log, id, mujica.HeikinAshiChart(c, candles, &s.window, &s.raw, opts)) + if before != s.raw { + log(fmt.Sprintf("Raw: %v", s.raw)) + } + case "144": + if mujica.NumberInput(c, &s.previous, mujica.NumberInputOptions{Min: 80, Max: 120, Step: 0.5, Label: "Previous close"}).Changed() { + log(fmt.Sprintf("Previous close: %.2f", s.previous)) + } + if mujica.Checkbox(c, &s.extended, "Extended sessions", mujica.CheckboxOptions{}).Changed() { + log(fmt.Sprintf("Extended sessions: %v", s.extended)) + } + o := mujica.LineQuoteChartOptions{Frame: frame, PrevClose: s.previous} + if s.extended { + o.RegularOpen = s.points[0].Time.Add(90 * time.Minute) + o.RegularClose = s.points[0].Time.Add(8 * time.Hour) + } + mktLog(log, id, mujica.LineQuoteChart(c, points, o)) + case "145": + opts.Frame.Height = 150 + chart() + frame.Label = "Volume" + frame.Height = 100 + mktLog(log, id, mujica.VolumeChart(c, candles, &s.window, mujica.VolumeChartOptions{Frame: frame})) + case "146": + mktLog(log, id, mujica.DepthChart(c, bids, asks, mujica.DepthChartOptions{Frame: frame})) + case "147": + if mujica.NumberInput(c, &s.fraction, mujica.NumberInputOptions{Min: 0.1, Max: 1, Step: 0.1, Label: "Value area fraction"}).Changed() { + log(fmt.Sprintf("Value area: %.1f", s.fraction)) + } + mktLog(log, id, mujica.VolumeProfile(c, bins, mujica.VolumeProfileOptions{Frame: frame, ValueArea: s.fraction})) + case "148": + if mujica.MultiChartLayout(c, &s.layout, mujica.MultiChartLayoutOptions{Height: 420}, func(i int, cell *mujica.ChartCell) { + if mujica.TextInput(c, &cell.Symbol, mujica.TextInputOptions{Label: fmt.Sprintf("Symbol %d", i+1)}).Changed() { + log(fmt.Sprintf("Cell %d: %s", i+1, cell.Symbol)) + } + items := make([]mujica.SelectOption[mujica.Interval], len(mujica.Intervals)) + for j, iv := range mujica.Intervals { + items[j] = mujica.SelectOption[mujica.Interval]{Value: iv, Label: string(iv)} + } + if mujica.Select(c, &cell.Interval, items, mujica.SelectOptions{Label: fmt.Sprintf("Interval %d", i+1)}).Changed() { + log(fmt.Sprintf("Cell %d: %s", i+1, cell.Interval)) + } + f := frame + f.Label = cell.Symbol + f.Height = 70 + mktLog(log, fmt.Sprint(i), mujica.CandlestickChart(c, candles, &cell.View, mujica.CandlestickOptions{Frame: f})) + }).Changed() { + log(fmt.Sprintf("Layout: %d, active %d", s.layout.Count, s.layout.Active)) + } + case "149": + for _, chartID := range []string{"price", "other"} { + ui.Column(c).Key(chartID).Children(func() { + if s.sync.Link(c, chartID).Changed() { + log("Sync: " + chartID) + } + v := &s.window + if chartID == "other" { + v = &s.second + } + o := opts + o.Frame.Height = 100 + o.Frame.Label = chartID + o.Sync = &s.sync + o.SyncID = chartID + mktLog(log, chartID, mujica.CandlestickChart(c, candles, v, o)) + }) + } + case "150": + if mujica.TimeRangeSelector(c, &s.rangeValue, mujica.TimeRangeSelectorOptions{}).Changed() { + log(fmt.Sprintf("Range: %d", s.rangeValue.Preset)) + } + from, to := s.rangeValue.Bounds(c.Now(), time.UTC) + ui.Text(c, from.Format("2006-01-02")+" — "+to.Format("2006-01-02")) + case "151": + before := fmt.Sprint(s.favorites) + if mujica.IntervalSelector(c, &s.interval, &s.favorites, mujica.IntervalSelectorOptions{}).Changed() { + log("Interval: " + string(s.interval)) + } + if before != fmt.Sprint(s.favorites) { + log("Favorites: " + fmt.Sprint(s.favorites)) + } + mujica.CandleCountdown(c, s.interval, mujica.CandleCountdownOptions{}) + case "152": + if mujica.ChartTypeSwitcher(c, &s.chartType, mujica.ChartTypeSwitcherOptions{}).Changed() { + log(fmt.Sprintf("Chart type: %d", s.chartType)) + } + opts.Type = s.chartType + chart() + case "153": + if mujica.IndicatorSelector(c, &s.overlays, &s.panes, mujica.IndicatorSelectorOptions{}).Changed() { + log("Overlays changed") + } + opts.Overlays = s.overlays + chart() + case "154": + if mujica.IndicatorSelector(c, &s.overlays, &s.panes, mujica.IndicatorSelectorOptions{}).Changed() { + log("Indicators changed") + } + r := mujica.IndicatorPane(c, candles, &s.window, &s.panes, mujica.IndicatorPaneOptions{Label: "BTC", Height: 320, Status: frame.Status, Error: frame.Error}) + if r.PointChanged { + log(fmt.Sprintf("Pane %d: point %d", r.Pane, r.Point)) + } + if r.Removed >= 0 { + log(fmt.Sprintf("Removed pane %d", r.Removed)) + } + if r.Resized { + log("Panes resized") + } + case "155": + if mujica.IndicatorSelector(c, &s.overlays, &s.panes, mujica.IndicatorSelectorOptions{}).Changed() { + log("Indicators changed") + } + opts.Overlays = s.overlays + chart() + case "156", "157": + if mujica.TextInput(c, &s.drawings.Text, mujica.TextInputOptions{Label: "Annotation text"}).Changed() { + log("Annotation text changed") + } + if mujica.DrawingToolbar(c, &s.drawings, mujica.DrawingToolbarOptions{Horizontal: true}).Changed() { + log(fmt.Sprintf("Tool: %d, locked: %v", s.drawings.Tool, s.drawings.Locked)) + } + before := fmt.Sprint(s.drawings.Items) + opts.Drawings = &s.drawings + chart() + if before != fmt.Sprint(s.drawings.Items) { + log(fmt.Sprintf("Drawings: %d", len(s.drawings.Items))) + } + case "158": + if mujica.CompareLegend(c, &s.compare).Changed() { + log("Compared symbol removed") + } + if mujica.Button(c, "Restore ETH", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + s.compare = mktInitial().compare + log("Restored ETH") + } + if len(candles) > 0 { + opts.Compare = &s.compare + } + chart() + case "159": + before := s.alerts[0].Price + opts.Alerts = &s.alerts + opts.OnAlert = func(a mujica.PriceAlertLine, last mujica.Candle) { + log(fmt.Sprintf("Alert %s crossed %.2f", a.ID, last.Close)) + } + if mujica.Button(c, "Append crossing candle", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() { + last := s.candles[len(s.candles)-1] + next := last + next.Time = last.Time.Add(time.Hour) + next.Open = last.Close + next.Close = s.alerts[0].Price + 2 + if last.Close > s.alerts[0].Price { + next.Close = s.alerts[0].Price - 2 + } + next.High = max(next.Open, next.Close) + 1 + next.Low = min(next.Open, next.Close) - 1 + s.candles = append(s.candles[:len(s.candles):len(s.candles)], next) + candles = s.candles + log("Appended candle") + } + chart() + if before != s.alerts[0].Price { + log(fmt.Sprintf("Alert price: %.2f", s.alerts[0].Price)) + } + case "160": + if mujica.IntervalSelector(c, &s.interval, &s.favorites, mujica.IntervalSelectorOptions{}).Changed() { + log("Interval: " + string(s.interval)) + } + if mujica.Checkbox(c, &s.hidden, "Hide countdown", mujica.CheckboxOptions{}).Changed() { + log(fmt.Sprintf("Hidden: %v", s.hidden)) + } + if !s.hidden { + mujica.CandleCountdown(c, s.interval, mujica.CandleCountdownOptions{}) + } + } + }) + }) +} diff --git a/examples/gallery/pages_mktchart_state.go b/examples/gallery/pages_mktchart_state.go new file mode 100644 index 0000000..8cd822d --- /dev/null +++ b/examples/gallery/pages_mktchart_state.go @@ -0,0 +1,98 @@ +package main + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + mujica "mujicaui" +) + +type mktDemoState struct { + candles []mujica.Candle + points []mujica.QuotePoint + bids, asks []mujica.DepthLevel + bins []mujica.ProfileBin + window mujica.MarketView + second mujica.MarketView + sync mujica.ChartSync + layout mujica.MultiChartState + interval mujica.Interval + favorites []mujica.Interval + rangeValue mujica.MarketRange + chartType mujica.ChartType + overlays []mujica.IndicatorOverlay + panes mujica.IndicatorPanes + drawings mujica.DrawingTools + compare []mujica.CompareSymbol + alerts []mujica.PriceAlertLine + status string + disabled, raw, redUp, hidden, extended bool + previous, fraction float64 +} + +func mktInitial() mktDemoState { + s := mktDemoState{status: "Ready", interval: "1h", favorites: []mujica.Interval{"1m", "1h", "1D"}, previous: 100, fraction: 0.7, extended: true} + start := time.Date(2026, 10, 5, 8, 0, 0, 0, time.UTC) + price := 100.0 + compare := make([]float64, 180) + for i := range 180 { + closeAt := price + math.Sin(float64(i)*0.7)*1.5 + math.Cos(float64(i)*0.13)*0.8 + s.candles = append(s.candles, mujica.Candle{Time: start.Add(time.Duration(i) * time.Hour), Open: price, High: max(price, closeAt) + 0.8, Low: min(price, closeAt) - 0.7, Close: closeAt, Volume: 1000 + float64(i%17)*130}) + s.points = append(s.points, mujica.QuotePoint{Time: start.Add(time.Duration(i) * 5 * time.Minute), Price: 100 + 2*math.Sin(float64(i)/10), Volume: 100 + float64(i%7)*20}) + compare[i] = 20 + float64(i)*0.08 + price = closeAt + } + for i := range 40 { + s.bids = append(s.bids, mujica.DepthLevel{Price: 99.9 - float64(i)*0.1, Size: float64(10 + i*3)}) + s.asks = append(s.asks, mujica.DepthLevel{Price: 100.1 + float64(i)*0.1, Size: float64(8 + i*4)}) + } + s.bins = mujica.VolumeProfileOf(s.candles, 24) + s.compare = []mujica.CompareSymbol{{Name: "ETH", Closes: compare}} + s.alerts = []mujica.PriceAlertLine{{ID: "target", Price: 104, Label: "Target"}} + s.overlays = []mujica.IndicatorOverlay{{Kind: mujica.IndicatorMA, Params: []float64{10}}} + s.panes = mujica.IndicatorPanes{Panes: []mujica.IndicatorPaneSpec{{Kind: mujica.IndicatorMACD}, {Kind: mujica.IndicatorRSI}}} + s.layout.Count = 2 + for i := range s.layout.Cells { + s.layout.Cells[i] = mujica.ChartCell{Symbol: fmt.Sprintf("Symbol %d", i+1), Interval: "1h"} + } + return s +} + +func mktControls(c *ui.Context, s *mktDemoState, log func(string)) mujica.ChartFrame { + ui.Row(c).Gap(12).Wrap().Children(func() { + if mujica.Select(c, &s.status, []mujica.SelectOption[string]{{Value: "Ready", Label: "Ready"}, {Value: "Loading", Label: "Loading"}, {Value: "Failed", Label: "Failed"}, {Value: "Empty", Label: "Empty"}}, mujica.SelectOptions{Label: "Data state"}).Changed() { + log("Data state: " + s.status) + } + if mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}).Changed() { + log(fmt.Sprintf("Disabled: %v", s.disabled)) + } + if mujica.Checkbox(c, &s.redUp, "Red up", mujica.CheckboxOptions{}).Changed() { + log(fmt.Sprintf("Red up: %v", s.redUp)) + } + }) + trend := mujica.GreenUp + if s.redUp { + trend = mujica.RedUp + } + mujica.UseTrend(c, trend) + f := mujica.ChartFrame{Label: "BTC", Height: 220} + switch s.status { + case "Loading": + f.Status = mujica.DataLoading + case "Failed": + f.Status = mujica.DataFailed + f.Error = "Archive unavailable" + } + return f +} + +func mktLog(log func(string), name string, r mujica.ChartResult) { + if r.Changed() { + log(fmt.Sprintf("%s: point %d", name, r.Point)) + } + if r.Retried() { + log(name + ": retry") + } +} diff --git a/examples/gallery/pages_mktchart_test.go b/examples/gallery/pages_mktchart_test.go new file mode 100644 index 0000000..af5ec52 --- /dev/null +++ b/examples/gallery/pages_mktchart_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" + mujica "mujicaui" +) + +func mktDemoTester(t *testing.T, id string) (*ui.Tester, *[]string) { + t.Helper() + events := &[]string{} + tt := ui.NewTester(func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + ui.Scroll(c).Fill().Padding(12).Children(func() { mktDemo(c, func(s string) { *events = append(*events, s) }, id) }) + }, 900, 800) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + return tt, events +} + +func TestMarketChartPagesHaveInteractiveControls(t *testing.T) { + if len(mktchartPages) != 20 { + t.Fatalf("pages %d", len(mktchartPages)) + } + for _, p := range mktchartPages { + t.Run(p.ID, func(t *testing.T) { + if p.Category != "Market Charts" || p.Code == "" || p.Keys == "" { + t.Fatal("incomplete page") + } + tt, events := mktDemoTester(t, p.ID) + if err := tt.Click("Red up"); err != nil { + t.Fatal(err) + } + if len(*events) == 0 || (*events)[len(*events)-1] != "Red up: true" { + t.Fatalf("control events %q", *events) + } + if err := tt.Click("Disabled"); err != nil { + t.Fatal(err) + } + if (*events)[len(*events)-1] != "Disabled: true" { + t.Fatalf("disabled event %q", *events) + } + }) + } +} + +func TestMarketChartDemosLogKeyboardAndAlerts(t *testing.T) { + for _, id := range []string{"141", "142", "143", "144", "145", "146", "147", "153", "155", "156", "157", "158", "159"} { + t.Run(id, func(t *testing.T) { + tt, events := mktDemoTester(t, id) + tabUntil(t, tt, "BTC") + tt.Key(0, ui.KeyRight) + want := id + ": point 0" + found := false + for _, e := range *events { + found = found || e == want + } + if !found { + t.Fatalf("keyboard event %q, want %s", *events, want) + } + }) + } + tt, events := mktDemoTester(t, "159") + if err := tt.Click("Append crossing candle"); err != nil { + t.Fatal(err) + } + found := false + for _, e := range *events { + found = found || strings.HasPrefix(e, "Alert target crossed ") + } + if !found { + t.Fatalf("alert events %q", *events) + } +} diff --git a/heikin_ashi_chart_test.go b/heikin_ashi_chart_test.go new file mode 100644 index 0000000..40c9f2f --- /dev/null +++ b/heikin_ashi_chart_test.go @@ -0,0 +1,58 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestHeikinAshiChartSwitchesToRaw(t *testing.T) { + candles := sampleCandles(30) + view := MarketView{Count: 30} + raw := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + HeikinAshiChart(c, candles, &view, &raw, CandlestickOptions{Frame: ChartFrame{Label: "HA"}}) + }, 600, 360) + ha := HeikinAshi(candles) + readout := func() string { + for _, s := range tt.Texts() { + if strings.HasPrefix(s, "2026-01-01 00:00") { + return s + } + } + return "" + } + tt.Key(0, ui.KeyTab) // the switch + tt.Key(0, ui.KeyTab) // the plot + tt.Key(0, ui.KeyHome) + if want := "O " + priceUngrouped(ha[0].Open, 2); !strings.Contains(readout(), want) { + t.Fatalf("Heikin-Ashi readout %q, want %q", readout(), want) + } + if err := tt.Click("Candles"); err != nil || !raw { + t.Fatalf("switch: %v %v", err, raw) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyHome) + if want := "O " + priceUngrouped(candles[0].Open, 2); !strings.Contains(readout(), want) { + t.Fatalf("raw readout %q, want %q", readout(), want) + } + defer func() { + if recover() == nil { + t.Fatal("nil raw accepted") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); HeikinAshiChart(c, candles, &view, nil, CandlestickOptions{}) }, 200, 100) +} + +func TestHeikinAshiChartSnapshot(t *testing.T) { + candles := sampleCandles(70) + view := MarketView{Count: 60} + raw := false + snapshot(t, "heikin_ashi_chart", 640, 340, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + HeikinAshiChart(c, candles, &view, &raw, CandlestickOptions{Frame: ChartFrame{Label: "HA", Height: 230}}) + }) + }) +} diff --git a/indicator_overlay.go b/indicator_overlay.go index 0484e58..8531155 100644 --- a/indicator_overlay.go +++ b/indicator_overlay.go @@ -2,6 +2,8 @@ package mujica import ( "fmt" + "log/slog" + "math" "strconv" "strings" @@ -24,7 +26,7 @@ const ( IndicatorOBV ) -// indicatorInfo is a kind's name, default parameters and where it draws. +// indicatorInfo describes names, default parameters, and plotting locations. var indicatorInfo = []struct { name string params []float64 @@ -47,17 +49,18 @@ func (k IndicatorKind) DefaultParams() []float64 { return append([]float64(nil), indicatorInfo[k].params...) } -// IndicatorOverlay is an indicator drawn over the price chart: MA, EMA, BOLL or VWAP. +// IndicatorOverlay configures MA, EMA, BOLL, or VWAP. type IndicatorOverlay struct { Kind IndicatorKind - // Params are the periods (and BOLL's width); nil uses the kind's defaults. + // Params nil uses the indicator's defaults. Params []float64 - // Values, when set, are the caller's own series, one per candle, drawn instead of the built-in result. + // Values overrides calculation; supply one value per candle. Values []float64 } // indicatorParams resolves and checks params for kind. func indicatorParams(k IndicatorKind, params []float64) []float64 { + _ = k.String() want := indicatorInfo[k].params if params == nil { return want @@ -66,10 +69,13 @@ func indicatorParams(k IndicatorKind, params []float64) []float64 { panic(fmt.Sprintf("mujica: %v takes %d parameters, not %d", k, len(want), len(params))) } for i, p := range params { - if !(p > 0) || (k != IndicatorBOLL || i == 0) && p != float64(int(p)) { + if math.IsInf(p, 0) || !(p > 0) || (k != IndicatorBOLL || i == 0) && p != float64(int(p)) { panic(fmt.Sprintf("mujica: %v parameter %v must be a positive whole number", k, p)) } } + if k == IndicatorMACD && params[0] >= params[1] { + panic("mujica: MACD fast period must be below slow period") + } return params } @@ -85,7 +91,7 @@ func indicatorName(k IndicatorKind, params []float64) string { return k.String() + "(" + strings.Join(parts, ",") + ")" } -// indicatorSeries computes kind over candles: named lines, one value per candle. +// indicatorSeries computes named indicator lines from candles. func indicatorSeries(k IndicatorKind, params []float64, candles []Candle) []ChartSeries { p := indicatorParams(k, params) closes := make([]float64, len(candles)) @@ -115,7 +121,7 @@ func indicatorSeries(k IndicatorKind, params []float64, candles []Candle) []Char return []ChartSeries{{name, OBV(candles)}} } -// mktOverlayLines resolves the chart's overlays, computing each only when its candles or settings change. +// mktOverlayLines caches indicators until data or parameters change. func mktOverlayLines(ch *chart, m *mktCore, candles []Candle) []mktLine { pal := internal.Palette(m.k) var out []mktLine @@ -123,7 +129,7 @@ func mktOverlayLines(ch *chart, m *mktCore, candles []Candle) []mktLine { if _ = o.Kind.String(); !indicatorInfo[o.Kind].overlay { panic(fmt.Sprintf("mujica: %v is not an overlay indicator", o.Kind)) } - col := pal[(len(pal)-2-i+len(pal))%len(pal)] + col := pal[[]int{3, 1, 5, 2, 4}[i%5]] if o.Values != nil { if len(o.Values) != len(candles) { panic("mujica: IndicatorOverlay Values need one value per candle") @@ -140,16 +146,21 @@ func mktOverlayLines(ch *chart, m *mktCore, candles []Candle) []mktLine { return out } -// IndicatorParamsField edits one indicator's parameters in place; Changed reports a valid new value. +// IndicatorParamsField edits validated indicator parameters in place. func IndicatorParamsField(c *ui.Context, kind IndicatorKind, params *[]float64) ChoiceResult { if params == nil { panic("mujica: IndicatorParamsField needs params") } + row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Role(ui.RoleGroup).Label(kind.String()) + if row.IsDisabled() { + copy := append([]float64(nil), (*params)...) + params = © + } if *params == nil { *params = kind.DefaultParams() } changed := false - row := ui.Row(c).Gap(Density(c).Unit() * 2).AlignItems(ui.Center).Role(ui.RoleGroup).Label(kind.String()) + indicatorParams(kind, *params) row.Children(func() { for i := range *params { v := (*params)[i] @@ -165,5 +176,8 @@ func IndicatorParamsField(c *ui.Context, kind IndicatorKind, params *[]float64) } } }) + if changed { + slog.Debug("mujica: indicator_overlay params", "kind", kind) + } return ChoiceResult{row, changed} } diff --git a/indicator_overlay_test.go b/indicator_overlay_test.go new file mode 100644 index 0000000..06d7c32 --- /dev/null +++ b/indicator_overlay_test.go @@ -0,0 +1,95 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestIndicatorOverlayValuesInReadoutAndMemo(t *testing.T) { + candles := sampleCandles(200) + view := MarketView{Count: 60} + overlays := []IndicatorOverlay{{Kind: IndicatorMA, Params: []float64{5}}, {Kind: IndicatorBOLL}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}, Overlays: overlays}) + }, 800, 400) + if !tt.HasText("MA(5)") || !tt.HasText("BOLL(20,2) ↑") { + t.Fatalf("legend %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + ma := SMA(closesOf(candles), 5) + want := "MA(5) " + priceUngrouped(ma[199], 2) + found := false + for _, s := range tt.Texts() { + found = found || strings.Contains(s, want) + } + if !found { + t.Fatalf("readout lacks %q: %q", want, tt.Texts()) + } + // Caller values replace the computation. + own := make([]float64, 200) + for i := range own { + own[i] = 42 + } + overlays = []IndicatorOverlay{{Kind: IndicatorEMA, Values: own}} + tt.Frame() + tt.Frame() + if !tt.HasText("EMA(20) 42.00") { + t.Fatalf("own values %q", tt.Texts()) + } + for name, o := range map[string]IndicatorOverlay{ + "pane kind": {Kind: IndicatorMACD}, + "params": {Kind: IndicatorMA, Params: []float64{0}}, + "values size": {Kind: IndicatorMA, Values: []float64{1}}, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}, Overlays: []IndicatorOverlay{o}}) + }, 300, 200) + }() + } +} + +func closesOf(cs []Candle) []float64 { + out := make([]float64, len(cs)) + for i, c := range cs { + out[i] = c.Close + } + return out +} + +func TestIndicatorParamsField(t *testing.T) { + var params []float64 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + IndicatorParamsField(c, IndicatorMACD, ¶ms) + }, 500, 100) + if len(params) != 3 || params[0] != 12 { + t.Fatalf("defaults %v", params) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyUp) + if params[0] != 13 { + t.Fatalf("Up on fast %v", params) + } +} + +func TestIndicatorOverlaySnapshot(t *testing.T) { + candles := sampleCandles(120) + view := MarketView{Count: 90} + snapshot(t, "indicator_overlay", 640, 340, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 250}, + Overlays: []IndicatorOverlay{{Kind: IndicatorMA, Params: []float64{10}}, {Kind: IndicatorBOLL}}}) + }) + }) +} diff --git a/indicator_pane.go b/indicator_pane.go index 547d864..f719bb5 100644 --- a/indicator_pane.go +++ b/indicator_pane.go @@ -20,37 +20,53 @@ type IndicatorPaneSpec struct { Params []float64 } -// IndicatorPanes is the caller-held stack of panes and their height shares. +// IndicatorPanes holds stacked indicators and their height shares. type IndicatorPanes struct { Panes []IndicatorPaneSpec - // Sizes are the panes' height shares; they reset to equal when the pane count changes. + // Sizes reset to equal shares when pane count changes. Sizes []float32 } // IndicatorPaneOptions configure an IndicatorPane. type IndicatorPaneOptions struct { - // Height is the whole stack's height; zero is 140 per pane. + // Height defaults to 140 per pane. Height float32 // Label prefixes each pane's accessible name, as the symbol. Label string Disabled bool + Status DataStatus + Error string + Empty string } // IndicatorPaneResult reports the stack's events. type IndicatorPaneResult struct { Element *ui.Element - // Removed is the index of the pane closed this frame, -1 for none. + // Removed reports the closed pane; -1 means none. Removed int // Resized reports finished height changes, for saving. - Resized bool + Resized bool + Point int + Pane int + PointChanged bool } -// IndicatorPane stacks indicator panes under a price chart, sharing its view; dividers between them resize. +// IndicatorPane stacks resizable technical indicators using the shared window. func IndicatorPane(c *ui.Context, candles []Candle, view *MarketView, panes *IndicatorPanes, opts IndicatorPaneOptions) IndicatorPaneResult { if view == nil || panes == nil { panic("mujica: IndicatorPane needs a view and panes") } - res := IndicatorPaneResult{Removed: -1} + box := ui.Column(c).FillWidth().Disabled(opts.Disabled) + if box.IsDisabled() { + copy := *panes + copy.Panes = append([]IndicatorPaneSpec(nil), panes.Panes...) + copy.Sizes = append([]float32(nil), panes.Sizes...) + panes = © + } + if opts.Height < 0 { + panic("mujica: IndicatorPane Height must be non-negative") + } + res := IndicatorPaneResult{Removed: -1, Point: -1, Pane: -1} n := len(panes.Panes) for _, p := range panes.Panes { if _ = p.Kind.String(); indicatorInfo[p.Kind].overlay { @@ -59,12 +75,16 @@ func IndicatorPane(c *ui.Context, candles []Candle, view *MarketView, panes *Ind indicatorParams(p.Kind, p.Params) } if n == 0 { - res.Element = ui.Column(c) + res.Element = box return res } if len(panes.Sizes) != n { panes.Sizes = nil } + u := Density(c).Unit() + lineH := FontSize(c, theme.CaptionSize) * theme.LineHeight + chrome := 2*max(theme.MinHitSize, lineH) + max(u*5, lineH) + 4*u + minimum := chrome + 40 h := opts.Height if h == 0 { h = 140 * float32(n) @@ -74,17 +94,21 @@ func IndicatorPane(c *ui.Context, candles []Candle, view *MarketView, panes *Ind col := ui.Column(c).Fill().MinHeight(0) b := col.Bounds() internal.Remeasure(c, col, b.W, b.H) - u := Density(c).Unit() - // The plot takes what the title, legend and readout rows leave. - plotH := max(40, b.H-2*theme.MinHitSize-u*12) + plotH := max(40, b.H-chrome) col.Children(func() { - if indicatorPaneOne(c, candles, view, panes.Panes[i], opts, plotH) { + closed, point := indicatorPaneOne(c, candles, view, panes.Panes[i], opts, plotH) + if point.Point >= 0 { + res.Point = point.Point + res.Pane = i + } + res.PointChanged = res.PointChanged || point.Changed() + if closed { res.Removed = i } }) } } - box := ui.Column(c).Height(h).FillWidth().Disabled(opts.Disabled) + box.Height(h).MinHeight(float32(n)*minimum + float32(n-1)*theme.BorderWidth) box.Children(func() { if n == 1 { build(0)() @@ -92,7 +116,7 @@ func IndicatorPane(c *ui.Context, candles []Candle, view *MarketView, panes *Ind } list := make([]ResizablePanel, n) for i, p := range panes.Panes { - list[i] = ResizablePanel{Label: indicatorName(p.Kind, indicatorParams(p.Kind, p.Params)), Min: 60, Build: build(i)} + list[i] = ResizablePanel{Label: indicatorName(p.Kind, indicatorParams(p.Kind, p.Params)), Min: minimum, Build: build(i)} } r := ResizablePanelGroup(c, &panes.Sizes, list, ResizablePanelGroupOptions{Vertical: true}) r.Element.Fill() @@ -108,8 +132,8 @@ func IndicatorPane(c *ui.Context, candles []Candle, view *MarketView, panes *Ind return res } -// indicatorPaneOne draws one pane's lines over the view's window; it reports the close button. -func indicatorPaneOne(c *ui.Context, candles []Candle, view *MarketView, spec IndicatorPaneSpec, opts IndicatorPaneOptions, height float32) bool { +// indicatorPaneOne draws one indicator and reports its events. +func indicatorPaneOne(c *ui.Context, candles []Candle, view *MarketView, spec IndicatorPaneSpec, opts IndicatorPaneOptions, height float32) (bool, ChartResult) { k := Tokens(c) params := indicatorParams(spec.Kind, spec.Params) name := indicatorName(spec.Kind, params) @@ -119,7 +143,7 @@ func indicatorPaneOne(c *ui.Context, candles []Candle, view *MarketView, spec In closed = iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "indicator_pane.remove"), name)).Clicked() }) label := strings.TrimSpace(opts.Label + " " + name) - ch := newChart(c, ChartFrame{Label: label, Height: height}) + ch := newChart(c, ChartFrame{Label: label, Height: height, Status: opts.Status, Error: opts.Error, Empty: opts.Empty}) series := chartMemo(ch, "series", fmt.Sprintf("%s|%v|%v", chartID(candles), spec.Kind, params), func() []ChartSeries { marketCheckCandles(candles) return indicatorSeries(spec.Kind, params, candles) @@ -133,7 +157,7 @@ func indicatorPaneOne(c *ui.Context, candles []Candle, view *MarketView, spec In lines[i] = mktLine{name: s.Name, values: s.Values, color: pal[(i+1)%len(pal)], shape: i} keys[i] = chartKey{Name: s.Name, Color: lines[i].color, Shape: i} } - yr := chartMemo(ch, "y", [3]any{chartID(candles), m.from, m.to}, func() [2]float64 { + yr := chartMemo(ch, "y", [5]any{chartID(candles), m.from, m.to, spec.Kind, fmt.Sprint(params)}, func() [2]float64 { lo, hi := math.Inf(1), math.Inf(-1) for _, l := range lines { for i := m.from; i < m.to; i++ { @@ -185,8 +209,16 @@ func indicatorPaneOne(c *ui.Context, candles []Candle, view *MarketView, spec In p.Line(x, g.plot.Y, x, g.plot.Y+g.plot.H, 1, k.TextMuted) } } - ch.build(spec2) - return closed && !ch.res.Element.IsDisabled() + m.st = *ui.Local(ch.res.Element, "mujica.mkt", func() *mktState { return &mktState{grab: -1, focus: -1, alertFocus: -1} }) + if ch.res.Element.IsDisabled() { + copy := *view + m.view = © + } + result, plot := ch.build(spec2) + if plot != nil { + m.input(plot) + } + return closed && !ch.res.Element.IsDisabled(), result } // paneGeom is geom without the time axis, for lower panes. diff --git a/indicator_selector.go b/indicator_selector.go index 7e82067..2e687ba 100644 --- a/indicator_selector.go +++ b/indicator_selector.go @@ -17,7 +17,7 @@ type IndicatorSelectorOptions struct { Disabled bool } -// IndicatorSelector opens a panel to search, add, remove and tune indicators: overlays go on the price chart, the rest into panes. +// IndicatorSelector searches, adds, removes, and configures chart indicators. func IndicatorSelector(c *ui.Context, overlays *[]IndicatorOverlay, panes *IndicatorPanes, opts IndicatorSelectorOptions) ChoiceResult { if overlays == nil || panes == nil { panic("mujica: IndicatorSelector needs overlays and panes") @@ -97,7 +97,7 @@ func IndicatorSelector(c *ui.Context, overlays *[]IndicatorOverlay, panes *Indic return ChoiceResult{box, changed} } -// indicatorSelectorActive is one added indicator with its parameters and a remove button; it reports removal. +// indicatorSelectorActive edits parameters and reports removal of an indicator. func indicatorSelectorActive(c *ui.Context, u float32, key string, kind IndicatorKind, params *[]float64, changed *bool) bool { removed := false ui.Row(c).Key(key).AlignItems(ui.Center).Gap(u * 2).Wrap().Children(func() { diff --git a/indicator_selector_test.go b/indicator_selector_test.go new file mode 100644 index 0000000..b826a25 --- /dev/null +++ b/indicator_selector_test.go @@ -0,0 +1,77 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" +) + +func TestIndicatorSelectorSearchAddRemoveAndDisabled(t *testing.T) { + var overlays []IndicatorOverlay + var panes IndicatorPanes + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + IndicatorSelector(c, &overlays, &panes, IndicatorSelectorOptions{Disabled: disabled}) + }, 640, 740) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if err := tt.Click("Indicators (0)"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Search indicators"); err != nil { + t.Fatal(err) + } + tt.Type("RSI") + if tt.HasText("Add MA") || !tt.HasText("RSI") { + t.Fatalf("search %q", tt.Texts()) + } + if err := tt.Click("Add RSI"); err != nil { + t.Fatal(err) + } + if len(panes.Panes) != 1 || panes.Panes[0].Kind != IndicatorRSI { + t.Fatalf("panes %+v", panes.Panes) + } + if err := tt.Click("Remove RSI(14)"); err != nil { + t.Fatal(err) + } + if len(panes.Panes) != 0 { + t.Fatal("pane not removed") + } + disabled = true + tt.Frame() + if tt.HasText("Search indicators") { + t.Fatal("disabled panel remained open") + } +} +func TestIndicatorSelectorSnapshot(t *testing.T) { + overlays := []IndicatorOverlay{{Kind: IndicatorMA}} + panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorRSI}}} + snapshot(t, "indicator_selector", 360, 100, func(c *ui.Context) { + ui.Row(c).Padding(12).Children(func() { IndicatorSelector(c, &overlays, &panes, IndicatorSelectorOptions{}) }) + }) +} + +func TestIndicatorSelectorOverlayParams(t *testing.T) { + var overlays []IndicatorOverlay + var panes IndicatorPanes + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + IndicatorSelector(c, &overlays, &panes, IndicatorSelectorOptions{}) + }, 640, 740) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if err := tt.Click("Indicators (0)"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Add MA"); err != nil { + t.Fatal(err) + } + if len(overlays) != 1 || overlays[0].Kind != IndicatorMA || overlays[0].Params[0] != 20 { + t.Fatalf("overlay %+v", overlays) + } + if err := tt.Click("MA 1"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyUp) + if overlays[0].Params[0] != 21 { + t.Fatalf("params %v", overlays[0].Params) + } +} diff --git a/indicators.go b/indicators.go index a11e475..f4321df 100644 --- a/indicators.go +++ b/indicators.go @@ -5,7 +5,7 @@ import ( "math" ) -// Indicator functions are pure: same input, same output, NaN until enough data. +// Indicator helpers are pure; NaN marks insufficient data. // indicatorCheck panics on a period below one. func indicatorCheck(name string, n int) { @@ -22,7 +22,7 @@ func indicatorNaN(n int) []float64 { return out } -// SMA is the simple moving average of the last n values. +// SMA computes the n-period simple moving average. func SMA(vals []float64, n int) []float64 { indicatorCheck("SMA", n) out := indicatorNaN(len(vals)) @@ -39,7 +39,7 @@ func SMA(vals []float64, n int) []float64 { return out } -// EMA is the exponential moving average, k = 2/(n+1), seeded with the SMA of the first n values. +// EMA uses 2/(n+1), seeded by the first n-period SMA. func EMA(vals []float64, n int) []float64 { indicatorCheck("EMA", n) out := indicatorNaN(len(vals)) @@ -60,7 +60,7 @@ func EMA(vals []float64, n int) []float64 { return out } -// Bollinger returns the n-period SMA and the bands width population deviations away. +// Bollinger returns SMA and population-deviation bands. func Bollinger(vals []float64, n int, width float64) (mid, upper, lower []float64) { indicatorCheck("Bollinger", n) if !(width > 0) { @@ -79,7 +79,7 @@ func Bollinger(vals []float64, n int, width float64) (mid, upper, lower []float6 return mid, upper, lower } -// VWAP is the cumulative volume-weighted average of the typical price (H+L+C)/3. +// VWAP averages typical prices weighted by cumulative volume. func VWAP(candles []Candle) []float64 { out := indicatorNaN(len(candles)) var pv, vol float64 @@ -93,7 +93,7 @@ func VWAP(candles []Candle) []float64 { return out } -// MACD returns DIF (fast EMA - slow EMA), DEA (signal EMA of DIF) and the histogram DIF - DEA. +// MACD returns DIF, signal EMA, and their difference. func MACD(vals []float64, fast, slow, signal int) (dif, dea, hist []float64) { if fast >= slow { panic("mujica: MACD fast period must be shorter than slow") @@ -115,7 +115,7 @@ func MACD(vals []float64, fast, slow, signal int) (dif, dea, hist []float64) { return dif, dea, hist } -// RSI is Wilder's relative strength index over n periods, 0 to 100. +// RSI computes Wilder's relative strength index. func RSI(vals []float64, n int) []float64 { indicatorCheck("RSI", n) out := indicatorNaN(len(vals)) @@ -143,7 +143,7 @@ func RSI(vals []float64, n int) []float64 { return out } -// KDJ is the stochastic oscillator: RSV over n, K and D smoothed by m1 and m2 from 50, J = 3K - 2D. +// KDJ computes stochastic K, D, and J from RSV. func KDJ(candles []Candle, n, m1, m2 int) (k, d, j []float64) { indicatorCheck("KDJ", n) indicatorCheck("KDJ m1", m1) @@ -166,7 +166,7 @@ func KDJ(candles []Candle, n, m1, m2 int) (k, d, j []float64) { return k, d, j } -// OBV is on-balance volume: volume added on up closes, subtracted on down closes. +// OBV accumulates signed volume according to close direction. func OBV(candles []Candle) []float64 { out := make([]float64, len(candles)) for i := 1; i < len(candles); i++ { diff --git a/indicators_test.go b/indicators_test.go index d1ad5cd..c73b94a 100644 --- a/indicators_test.go +++ b/indicators_test.go @@ -23,7 +23,6 @@ var nan = math.NaN() func TestSMAAndEMA(t *testing.T) { v := []float64{1, 2, 3, 4, 5, 6} approx(t, "SMA", SMA(v, 3), []float64{nan, nan, 2, 3, 4, 5}) - // k = 0.5: seed 2, then 4*0.5+2*0.5 = 3, 5*0.5+3*0.5 = 4, 6*0.5+4*0.5 = 5. approx(t, "EMA", EMA(v, 3), []float64{nan, nan, 2, 3, 4, 5}) approx(t, "EMA2", EMA([]float64{10, 20, 10}, 1), []float64{10, 20, 10}) approx(t, "EMA3", EMA([]float64{2, 4, 8}, 2), []float64{nan, 3, 3 + (8-3)*2.0/3}) @@ -65,27 +64,23 @@ func TestMACD(t *testing.T) { t.Fatalf("dif[%d]", i) } } - // DEA is the EMA(2) of DIF from index 2: seed is the mean of dif[2], dif[3]. want := EMA(dif[2:], 2) approx(t, "dea", dea[2:], want) if !math.IsNaN(dea[1]) || math.Abs(hist[6]-(dif[6]-dea[6])) > 1e-12 { t.Fatal("hist or warm-up wrong") } - // A straight line: both EMAs trail by a constant, DIF = 0.5, DEA = 0.5, hist = 0. approx(t, "dif line", dif[2:], []float64{0.5, 0.5, 0.5, 0.5, 0.5}) approx(t, "hist line", hist[3:], []float64{0, 0, 0, 0}) } func TestRSI(t *testing.T) { - // Gains 1,1 then a loss of 1: first RSI (n=2) at i=2 is all gains = 100; then Wilder smoothing. r := RSI([]float64{1, 2, 3, 2}, 2) - approx(t, "RSI", r, []float64{nan, nan, 100, 100 - 100/(1+0.5/0.5)}) + approx(t, "RSI", r, []float64{nan, nan, 100, 50}) } func TestKDJ(t *testing.T) { cs := candlesOf([5]float64{1, 2, 0, 1, 0}, [5]float64{1, 4, 0, 4, 0}, [5]float64{4, 4, 2, 3, 0}) k, d, j := KDJ(cs, 2, 3, 3) - // i=1: range 0..4, RSV 100, K = (2*50+100)/3, D = (2*50+K)/3. k1 := 200.0 / 3 d1 := (100 + k1) / 3 // i=2: range 0..4, RSV 75. diff --git a/interval_selector.go b/interval_selector.go index 693ebcf..b7e430c 100644 --- a/interval_selector.go +++ b/interval_selector.go @@ -27,7 +27,7 @@ type IntervalSelectorOptions struct { Disabled bool } -// IntervalSelector picks a candle period: favorites show as quick buttons, and a grouped list holds all, each with a star. +// IntervalSelector groups candle periods and preserves caller-owned favorites. func IntervalSelector(c *ui.Context, value *Interval, favorites *[]Interval, opts IntervalSelectorOptions) ChoiceResult { if value == nil || favorites == nil { panic("mujica: IntervalSelector needs a value and favorites") @@ -81,7 +81,7 @@ func IntervalSelector(c *ui.Context, value *Interval, favorites *[]Interval, opt return ChoiceResult{row, changed} } -// intervalSelectorRow is one interval with its star; it reports choosing the interval. +// intervalSelectorRow shows a selectable period and its favorite toggle. func intervalSelectorRow(c *ui.Context, k theme.Tokens, u float32, iv Interval, on, fav bool, favorites *[]Interval) bool { chosen := false ui.Row(c).Key(string(iv)).AlignItems(ui.Center).Gap(u).Children(func() { diff --git a/interval_selector_test.go b/interval_selector_test.go new file mode 100644 index 0000000..910c87a --- /dev/null +++ b/interval_selector_test.go @@ -0,0 +1,61 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" +) + +func TestIntervalSelectorGroupsFavoritesAndDisabled(t *testing.T) { + value := Interval("1h") + favorites := []Interval{"1m", "1h"} + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + IntervalSelector(c, &value, &favorites, IntervalSelectorOptions{Disabled: disabled}) + }, 500, 700) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + if err := tt.Click("All intervals"); err != nil { + t.Fatal(err) + } + if !tt.HasText("Minutes") || !tt.HasText("Hours") || !tt.HasText("Days and longer") { + t.Fatalf("groups %q", tt.Texts()) + } + if err := tt.Click("Add 4h to favorites"); err != nil { + t.Fatal(err) + } + if len(favorites) != 3 || favorites[2] != "4h" { + t.Fatalf("favorites %v", favorites) + } + for range 50 { + if tt.Focused("1M") { + break + } + tt.Key(0, ui.KeyTab) + } + if !tt.Focused("1M") { + t.Fatal("keyboard could not reach 1M") + } + tt.Key(0, ui.KeyEnter) + if value != "1M" { + t.Fatalf("interval %v", value) + } + if err := tt.Click("All intervals"); err != nil { + t.Fatal(err) + } + disabled = true + tt.Frame() + if tt.HasText("Days and longer") { + t.Fatal("disabled popup remained open") + } + tt.Key(ui.Alt, ui.KeyDown) + if value != "1M" || len(favorites) != 3 { + t.Fatal("disabled interval changed") + } +} +func TestIntervalSelectorSnapshot(t *testing.T) { + value := Interval("1h") + favorites := []Interval{"1m", "5m", "1h", "1D"} + snapshot(t, "interval_selector", 460, 90, func(c *ui.Context) { + ui.Row(c).Padding(12).Children(func() { IntervalSelector(c, &value, &favorites, IntervalSelectorOptions{}) }) + }) +} diff --git a/line_quote_chart.go b/line_quote_chart.go index 82dafc1..3d890a5 100644 --- a/line_quote_chart.go +++ b/line_quote_chart.go @@ -2,6 +2,7 @@ package mujica import ( "fmt" + "math" "strings" "time" @@ -11,7 +12,7 @@ import ( "mujicaui/theme" ) -// QuotePoint is one tick of an intraday line: time, price and the volume traded since the last point. +// QuotePoint holds time, price, and incremental volume. type QuotePoint struct { Time time.Time Price float64 @@ -21,14 +22,14 @@ type QuotePoint struct { // LineQuoteChartOptions configure a LineQuoteChart. type LineQuoteChartOptions struct { Frame ChartFrame - // PrevClose draws the baseline the day's change is measured from; required. + // PrevClose is the required positive baseline price. PrevClose float64 - // RegularOpen and RegularClose bound the regular session; points outside are pre- and after-market. Zero draws one session. + // RegularOpen and RegularClose optionally delimit extended trading sessions. RegularOpen, RegularClose time.Time Decimals int } -// lineQuoteSession names a point's session: -1 pre, 0 regular, 1 after. +// lineQuoteSession returns pre-market, regular, or after-market. func lineQuoteSession(t time.Time, opts LineQuoteChartOptions) int { switch { case opts.RegularOpen.IsZero(): @@ -41,9 +42,9 @@ func lineQuoteSession(t time.Time, opts LineQuoteChartOptions) int { return 0 } -// LineQuoteChart is an intraday line with the previous close as baseline, the average price, and shaded extended sessions. +// LineQuoteChart draws price, weighted average, and extended sessions. func LineQuoteChart(c *ui.Context, points []QuotePoint, opts LineQuoteChartOptions) ChartResult { - if !(opts.PrevClose > 0) { + if math.IsInf(opts.PrevClose, 0) || !(opts.PrevClose > 0) { panic("mujica: LineQuoteChart needs a positive PrevClose") } if opts.RegularOpen.IsZero() != opts.RegularClose.IsZero() || opts.RegularClose.Before(opts.RegularOpen) { @@ -56,7 +57,7 @@ func LineQuoteChart(c *ui.Context, points []QuotePoint, opts LineQuoteChartOptio out := make([]float64, len(points)) var pv, v float64 for i, p := range points { - if p.Time.IsZero() || i > 0 && !p.Time.After(points[i-1].Time) || !(p.Price > 0) || p.Volume < 0 { + if p.Time.IsZero() || i > 0 && !p.Time.After(points[i-1].Time) || !(p.Price > 0) || p.Volume < 0 || math.IsNaN(p.Volume) || math.IsInf(p.Volume, 0) || math.IsInf(p.Price, 0) { panic(fmt.Sprintf("mujica: LineQuoteChart point %d is out of order or invalid", i)) } pv += p.Price * p.Volume @@ -68,12 +69,12 @@ func LineQuoteChart(c *ui.Context, points []QuotePoint, opts LineQuoteChartOptio } return out }) - dom := chartMemo(ch, "y", chartID(points), func() [2]float64 { + dom := chartMemo(ch, "y", [2]any{chartID(points), opts.PrevClose}, func() [2]float64 { lo, hi := opts.PrevClose, opts.PrevClose for i, p := range points { lo, hi = min(lo, p.Price, avg[i]), max(hi, p.Price, avg[i]) } - // Symmetric around the previous close, so the baseline sits in the middle. + // Center the scale on the previous close. d := max(hi-opts.PrevClose, opts.PrevClose-lo) return [2]float64{opts.PrevClose - d, opts.PrevClose + d} }) @@ -124,6 +125,9 @@ func LineQuoteChart(c *ui.Context, points []QuotePoint, opts LineQuoteChartOptio if len(points) > 1 { ts, layout := internal.TimeTicks(points[0].Time, points[len(points)-1].Time, max(2, int(plot.W/90))) for _, t := range ts { + if layout == "Jan 2" { + layout = msg(c, "candlestick_chart.daily_time") + } tx, tl = append(tx, xs.Map(float64(t.Unix()))), append(tl, t.Format(layout)) } } @@ -132,14 +136,22 @@ func LineQuoteChart(c *ui.Context, points []QuotePoint, opts LineQuoteChartOptio for xx := plot.X; xx < plot.X+plot.W; xx += 6 { p.Line(xx, by, min(xx+3, plot.X+plot.W), by, 1, k.TextMuted) } - price := make([]float64, len(points)) - for i, pt := range points { - price[i] = pt.Price - } + price := chartMemo(ch, "prices", chartID(points), func() []float64 { + out := make([]float64, len(points)) + for i, pt := range points { + out[i] = pt.Price + } + return out + }) at := func(i int) float32 { return x(xs, i) } for li, vals := range [][]float64{price, avg} { col := []ui.Color{k.AccentText, k.Warning}[li] - chartRuns(vals, at, func(idx []int) { + runs := chartMemo(ch, fmt.Sprint("quote.runs.", li), [3]any{chartID(points), plot.X, plot.W}, func() [][]int { + var out [][]int + chartRuns(vals, at, func(idx []int) { out = append(out, idx) }) + return out + }) + for _, idx := range runs { var line ui.Path for j, i := range idx { px, py := internal.Snap(p, at(i)), internal.Snap(p, ys.Map(vals[i])) @@ -150,7 +162,11 @@ func LineQuoteChart(c *ui.Context, points []QuotePoint, opts LineQuoteChartOptio } } p.StrokePath(&line, []float32{1.75, 1.25}[li], col) - }) + for j := 0; j < len(idx); j += max(1, len(idx)/12) { + i := idx[j] + internal.Marker(p, []int{0, 3}[li], at(i), ys.Map(vals[i]), 5, col) + } + } } if active >= 0 { ax := internal.Snap(p, at(active)) diff --git a/market_boundary_test.go b/market_boundary_test.go new file mode 100644 index 0000000..91c9a79 --- /dev/null +++ b/market_boundary_test.go @@ -0,0 +1,68 @@ +package mujica + +import ( + "bytes" + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestMarketIndicatorDomainChangesWithParams(t *testing.T) { + cs := sampleCandles(300) + v := MarketView{Count: 80} + panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorRSI}}} + build := func(c *ui.Context) { + Use(c, Settings{}) + IndicatorPane(c, cs, &v, &panes, IndicatorPaneOptions{Label: "BTC"}) + } + tt := ui.NewTester(build, 640, 400) + panes.Panes[0].Kind = IndicatorOBV + tt.Frame() + fresh := ui.NewTester(build, 640, 400) + if !bytes.Equal(encode(t, tt.Image()), encode(t, fresh.Image())) { + t.Fatal("changed indicator retained its previous scale") + } + +} + +func TestMarketRejectsInvalidLayers(t *testing.T) { + for _, kind := range []string{"compare zero", "compare infinity", "alert infinity", "alert duplicate", "profile NaN", "depth NaN", "indicator infinity"} { + t.Run(kind, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("invalid data accepted") + } + }() + cs := sampleCandles(2) + v := MarketView{} + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + o := CandlestickOptions{Frame: ChartFrame{Label: "Price"}} + switch kind { + case "compare zero": + x := []CompareSymbol{{Name: "X", Closes: []float64{0, 1}}} + o.Compare = &x + case "compare infinity": + x := []CompareSymbol{{Name: "X", Closes: []float64{1, math.Inf(1)}}} + o.Compare = &x + case "alert infinity": + x := []PriceAlertLine{{ID: "a", Price: math.Inf(1)}} + o.Alerts = &x + case "alert duplicate": + x := []PriceAlertLine{{ID: "a", Price: 1}, {ID: "a", Price: 2}} + o.Alerts = &x + case "profile NaN": + VolumeProfile(c, []ProfileBin{{1, 2, math.NaN()}}, VolumeProfileOptions{Frame: o.Frame}) + return + case "depth NaN": + DepthChart(c, []DepthLevel{{math.NaN(), 1}}, []DepthLevel{{2, 1}}, DepthChartOptions{Frame: o.Frame}) + return + case "indicator infinity": + o.Overlays = []IndicatorOverlay{{Kind: IndicatorBOLL, Params: []float64{2, math.Inf(1)}}} + } + CandlestickChart(c, cs, &v, o) + }, 500, 320) + }) + } +} diff --git a/market_data.go b/market_data.go index 90961a5..87293a9 100644 --- a/market_data.go +++ b/market_data.go @@ -7,38 +7,46 @@ import ( "time" ) -// Candle is one period of trading: open, high, low, close and volume. +// Candle holds OHLC prices, time, and volume. type Candle struct { Time time.Time Open, High, Low, Close float64 Volume float64 } -// marketCheckCandles panics on candles out of time order or with impossible prices. +// marketCheckCandles validates chronological candles and finite OHLC values. func marketCheckCandles(candles []Candle) { for i, c := range candles { if c.Time.IsZero() || i > 0 && !c.Time.After(candles[i-1].Time) { panic(fmt.Sprintf("mujica: candle %d time is zero or not after the one before", i)) } + for _, v := range []float64{c.Open, c.High, c.Low, c.Close, c.Volume} { + if math.IsNaN(v) || math.IsInf(v, 0) { + panic("mujica: candle values must be finite") + } + } if !(c.Low <= min(c.Open, c.Close)) || !(c.High >= max(c.Open, c.Close)) || c.Volume < 0 || math.IsNaN(c.Volume) { panic(fmt.Sprintf("mujica: candle %d has impossible prices or volume", i)) } } } -// MarketView is the caller-held window of a market chart: zoom and pan. +// MarketView holds the visible candle window. type MarketView struct { // Count is how many candles show; zero shows 120. Count int - // End is one past the last shown candle; zero follows the newest. + // End is exclusive; zero follows the newest candle. End int } -// marketWindow resolves the view to [from, to) over n candles, at most room wide. +// marketWindow resolves the visible range, bounded by room. func marketWindow(v MarketView, n, room int) (from, to int) { if v.Count < 0 || v.End < 0 { panic("mujica: MarketView Count and End must not be negative") } + if n == 0 { + return 0, 0 + } count := v.Count if count == 0 { count = 120 @@ -51,7 +59,7 @@ func marketWindow(v MarketView, n, room int) (from, to int) { return to - count, to } -// marketPan moves the view by delta candles; reaching the newest follows it again. +// marketPan moves the window; the newest edge enables following. func marketPan(v *MarketView, n, room, delta int) { from, to := marketWindow(*v, n, room) v.Count = to - from @@ -63,7 +71,7 @@ func marketPan(v *MarketView, n, room, delta int) { } } -// marketZoom scales the shown count by f, keeping the newest shown candle, within 10 and room. +// marketZoom changes visible count while preserving the right edge. func marketZoom(v *MarketView, n, room int, f float64) { from, to := marketWindow(*v, n, room) count := int(math.Round(float64(to-from) * f)) @@ -76,18 +84,18 @@ func marketZoom(v *MarketView, n, room int, f float64) { } } -// marketIndexAt finds the candle at or before t, or -1 before the first. +// marketIndexAt finds the candle at or before t. func marketIndexAt(candles []Candle, t time.Time) int { return sort.Search(len(candles), func(i int) bool { return candles[i].Time.After(t) }) - 1 } -// Interval names a candle period: 1m 3m 5m 15m 30m 1h 2h 4h 6h 12h 1D 3D 1W 1M. +// Interval names a supported candle period. type Interval string // Intervals lists every Interval, shortest first. var Intervals = []Interval{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "12h", "1D", "3D", "1W", "1M"} -// Close returns when the candle holding t closes: periods align to the clock in loc, weeks start Monday. +// Close aligns periods locally; weeks start Monday. func (iv Interval) Close(t time.Time, loc *time.Location) time.Time { t = t.In(loc) day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) diff --git a/market_input.go b/market_input.go index 141facb..8fb7428 100644 --- a/market_input.go +++ b/market_input.go @@ -1,12 +1,14 @@ package mujica import ( + "fmt" "log/slog" + "math" "github.com/egoist/mygo/ui" ) -// input pans, zooms, drags alerts and drawings, and keeps the focused candle in view. +// input handles pan, zoom, alerts, drawings, and keyboard following. func (m *mktCore) input(plot *ui.Element) { if plot.IsDisabled() { return @@ -39,7 +41,7 @@ func (m *mktCore) input(plot *ui.Element) { marketZoom(m.view, n, room, 1.25) changed = true } - // The keyboard mark may leave the window: the view follows it. + // Keep the keyboard mark visible. f := m.ch.st.focus if f >= 0 && f != m.st.focus && !changed && (f < m.from || f >= m.to) { if f < m.from { @@ -60,6 +62,7 @@ func (m *mktCore) input(plot *ui.Element) { used = d.input(m, plot, g, x, y, over, held, dx, dy) } if !used { + m.alertsKeyboard(plot) used = m.alertsInput(g, x, y, held, dy) } if !used && held && dx != 0 && g.band.Step() > 0 { @@ -78,3 +81,42 @@ func (m *mktCore) input(plot *ui.Element) { m.c.Invalidate() } } + +func (m *mktCore) alertsKeyboard(plot *ui.Element) { + if m.opts.Alerts == nil || len(*m.opts.Alerts) == 0 || m.base != 0 { + return + } + a := *m.opts.Alerts + m.st.alertFocus = min(m.st.alertFocus, len(a)-1) + selected := false + if plot.Shortcut(0, ui.KeyPageDown) { + m.st.alertFocus = (m.st.alertFocus + 1) % len(a) + selected = true + } + if plot.Shortcut(0, ui.KeyPageUp) { + m.st.alertFocus = (max(m.st.alertFocus, 0) - 1 + len(a)) % len(a) + selected = true + } + if selected && m.opts.Drawings != nil { + d := m.opts.Drawings + d.sel, d.pending, d.Tool = 0, nil, ToolSelect + } + delta := 0 + if m.opts.Drawings == nil || m.opts.Drawings.Selected() < 0 { + if plot.Shortcut(ui.Alt, ui.KeyUp) { + delta = 1 + } + if plot.Shortcut(ui.Alt, ui.KeyDown) { + delta = -1 + } + } + if i := m.st.alertFocus; i >= 0 && (selected || delta != 0) { + spec := numberInputResolve(NumberInputOptions{Step: math.Pow10(-m.dec), Precision: m.dec}) + if delta != 0 { + a[i].Price = spec.stepBy(a[i].Price, delta) + slog.Debug("mujica: price_alert_line moved", "id", a[i].ID) + } + m.c.Announce(fmt.Sprintf(msg(m.c, "price_alert_line.tag"), priceUngrouped(a[i].Price, m.dec))) + m.c.Invalidate() + } +} diff --git a/market_keyboard_test.go b/market_keyboard_test.go new file mode 100644 index 0000000..a4f99b3 --- /dev/null +++ b/market_keyboard_test.go @@ -0,0 +1,54 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" +) + +func TestDrawingToolsKeyboardCreateAndSelect(t *testing.T) { + cs := sampleCandles(30) + v := MarketView{Count: 30} + d := DrawingTools{Tool: ToolTrendLine} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Drawing"}, Drawings: &d}) + }, 640, 340) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyHome) + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) + if len(d.Items) != 1 || d.Items[0].A.Time != cs[0].Time || d.Items[0].B.Time != cs[1].Time { + t.Fatalf("keyboard drawing %+v", d) + } + tt.Key(0, ui.KeyEscape) + tt.Key(ui.Alt, ui.KeyEnter) + if d.Selected() != 0 { + t.Fatalf("selection %d", d.Selected()) + } + tt.Key(0, ui.KeyDelete) + if len(d.Items) != 0 { + t.Fatal("keyboard deletion failed") + } +} + +func TestPriceAlertLineKeyboard(t *testing.T) { + cs := sampleCandles(30) + v := MarketView{Count: 30} + alerts := []PriceAlertLine{{ID: "a", Price: 100}, {ID: "b", Price: 105}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Alerts"}, Alerts: &alerts}) + }, 640, 340) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyPageDown) + tt.Key(ui.Alt, ui.KeyUp) + if alerts[0].Price != 100.01 || alerts[1].Price != 105 { + t.Fatalf("first alert %+v", alerts) + } + tt.Key(0, ui.KeyPageDown) + tt.Key(ui.Alt, ui.KeyDown) + if alerts[1].Price != 104.99 { + t.Fatalf("second alert %+v", alerts) + } +} diff --git a/market_large_test.go b/market_large_test.go new file mode 100644 index 0000000..826edd1 --- /dev/null +++ b/market_large_test.go @@ -0,0 +1,49 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" +) + +func TestDepthChartLargeDataBounded(t *testing.T) { + bids, asks := make([]DepthLevel, 100000), make([]DepthLevel, 100000) + for i := range bids { + bids[i] = DepthLevel{100 - float64(i)*0.0001, 1} + asks[i] = DepthLevel{101 + float64(i)*0.0001, 2} + } + var res ChartResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + res = DepthChart(c, bids, asks, DepthChartOptions{Frame: ChartFrame{Label: "Depth"}}) + }, 640, 340) + drawn := *ui.Local(res.Element, "mujica.depth.drawn", func() int { return 0 }) + if drawn <= 0 || drawn > 4*640+8 { + t.Fatalf("painted %d depth vertices", drawn) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + if res.Point != 199999 { + t.Fatalf("last point %d", res.Point) + } +} + +func TestVolumeProfileLargeDataBounded(t *testing.T) { + bins := make([]ProfileBin, 100000) + for i := range bins { + bins[i] = ProfileBin{float64(i), float64(i + 1), float64(i%19 + 1)} + } + var res ChartResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + res = VolumeProfile(c, bins, VolumeProfileOptions{Frame: ChartFrame{Label: "Profile"}}) + }, 640, 340) + drawn := *ui.Local(res.Element, "mujica.profile.drawn", func() int { return 0 }) + if drawn <= 0 || drawn > 4*340+4 { + t.Fatalf("painted %d profile bins", drawn) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + if res.Point != 99999 { + t.Fatalf("last bin %d", res.Point) + } +} diff --git a/market_layers.go b/market_layers.go index 881121c..c98aefb 100644 --- a/market_layers.go +++ b/market_layers.go @@ -11,7 +11,7 @@ import ( "mujicaui/internal" ) -// PriceAlertLine is a caller-held price alert drawn as a dashed line that the pointer drags. +// PriceAlertLine holds a draggable, caller-owned price threshold. type PriceAlertLine struct { ID string Price float64 @@ -19,40 +19,63 @@ type PriceAlertLine struct { Label string } -// CompareSymbol is another symbol's closes, aligned to the chart's candles, shown in percent. +// CompareSymbol holds aligned closes for percent comparison. type CompareSymbol struct { Name string Closes []float64 } -// compareLines adds the compare symbols' percent lines and sets percent mode. +// compareLines normalizes prices and compared symbols into percentages. func (m *mktCore) compareLines() { if m.opts.Compare == nil || len(*m.opts.Compare) == 0 || m.to <= m.from { return } m.base = m.candles[m.from].Close + if m.base == 0 { + panic("mujica: percent comparison needs a nonzero base close") + } pal := internal.Palette(m.k) + for i := range m.overlays { + line := &m.overlays[i] + values := line.values + base := m.base + line.values = chartMemo(m.ch, fmt.Sprint("compare.overlay.", i), [2]any{chartID(values), base}, func() []float64 { + out := make([]float64, len(values)) + for j, v := range values { + out[j] = (v/base - 1) * 100 + } + return out + }) + } for i, s := range *m.opts.Compare { if s.Name == "" || len(s.Closes) != len(m.candles) { panic(fmt.Sprintf("mujica: CompareSymbol %q needs a Name and one close per candle", s.Name)) } + chartMemo(m.ch, "compare.check."+s.Name, chartID(s.Closes), func() bool { + for _, v := range s.Closes { + if math.IsInf(v, 0) || (!math.IsNaN(v) && v <= 0) { + panic("mujica: compared closes must be positive or NaN") + } + } + return true + }) base := math.NaN() for j := m.from; j < m.to && math.IsNaN(base); j++ { base = s.Closes[j] } closes := s.Closes - vals := chartMemo(m.ch, "compare."+s.Name, [2]any{chartID(closes), base}, func() []float64 { + vals := chartMemo(m.ch, "compare."+s.Name, [2]any{chartID(closes), math.Float64bits(base)}, func() []float64 { out := make([]float64, len(closes)) for j, v := range closes { out[j] = (v/base - 1) * 100 } return out }) - m.overlays = append(m.overlays, mktLine{name: s.Name, values: vals, color: pal[(i+1)%len(pal)], shape: len(m.overlays)}) + m.overlays = append(m.overlays, mktLine{name: s.Name, values: vals, color: pal[(i+1)%len(pal)], shape: i + 1}) } } -// CompareLegend lists the compare symbols with their colors and a remove button each; Changed reports a removal. +// CompareLegend shows series markers and removal actions. func CompareLegend(c *ui.Context, symbols *[]CompareSymbol) ChoiceResult { if symbols == nil { panic("mujica: CompareLegend needs symbols") @@ -81,13 +104,22 @@ func CompareLegend(c *ui.Context, symbols *[]CompareSymbol) ChoiceResult { return ChoiceResult{row, removed >= 0} } -// alertsCheck runs OnAlert for each alert the newest close crossed since the last frame. +// alertsCheck fires callbacks when the latest close crosses thresholds. func (m *mktCore) alertsCheck() { + if m.opts.Alerts != nil { + seen := map[string]bool{} + for _, a := range *m.opts.Alerts { + if a.ID == "" || seen[a.ID] || math.IsNaN(a.Price) || math.IsInf(a.Price, 0) { + panic("mujica: alerts need unique IDs and finite prices") + } + seen[a.ID] = true + } + } if m.opts.Alerts == nil || len(m.candles) == 0 { return } last := m.candles[len(m.candles)-1] - if m.st.alertSeen && last.Close != m.st.lastClose && m.opts.OnAlert != nil { + if !m.ch.res.Element.IsDisabled() && m.st.alertSeen && last.Close != m.st.lastClose && m.opts.OnAlert != nil { for _, a := range *m.opts.Alerts { if (m.st.lastClose-a.Price)*(last.Close-a.Price) <= 0 && m.st.lastClose != a.Price { slog.Debug("mujica: price_alert_line crossed", "id", a.ID) @@ -98,7 +130,7 @@ func (m *mktCore) alertsCheck() { m.st.lastClose, m.st.alertSeen = last.Close, true } -// alertsInput drags an alert line that the press started on; it reports taking the drag. +// alertsInput drags the threshold captured by the initial press. func (m *mktCore) alertsInput(g mktGeom, x, y float32, held bool, dy float32) bool { if m.opts.Alerts == nil || m.base != 0 { return false @@ -110,7 +142,7 @@ func (m *mktCore) alertsInput(g mktGeom, x, y float32, held bool, dy float32) bo } if m.st.grab < 0 { for i, a := range alerts { - if math.Abs(float64(g.ys.Map(a.Price)-(y-dy))) <= 5 { + if g.plot.Contains(x, y-dy) && math.Abs(float64(g.ys.Map(a.Price)-(y-dy))) <= 14 { m.st.grab = i } } @@ -118,19 +150,22 @@ func (m *mktCore) alertsInput(g mktGeom, x, y float32, held bool, dy float32) bo if m.st.grab < 0 || m.st.grab >= len(alerts) { return false } - p := math.Pow(10, float64(m.dec)) - alerts[m.st.grab].Price = math.Round(g.ys.Invert(y)*p) / p + spec := numberInputResolve(NumberInputOptions{Step: math.Pow10(-m.dec), Precision: m.dec}) + alerts[m.st.grab].Price = spec.round(g.ys.Invert(y)) slog.Debug("mujica: price_alert_line moved", "id", alerts[m.st.grab].ID) return true } -// paintAlerts draws each alert as a dashed line with its price tag. +// paintAlerts draws dashed thresholds and price labels. func (m *mktCore) paintAlerts(p *ui.Painter, g mktGeom, size float32) { if m.opts.Alerts == nil || m.base != 0 { return } - for _, a := range *m.opts.Alerts { + for i, a := range *m.opts.Alerts { y := internal.Snap(p, g.ys.Map(a.Price)) + if i == m.st.alertFocus { + p.FillPath(internal.Diamond(internal.Snap(p, g.plot.X+8), y, 9), m.k.Warning) + } for x := g.plot.X; x < g.plot.X+g.plot.W; x += 8 { p.Line(x, y, min(x+4, g.plot.X+g.plot.W), y, 1, m.k.Warning) } diff --git a/market_regression_test.go b/market_regression_test.go new file mode 100644 index 0000000..db4267d --- /dev/null +++ b/market_regression_test.go @@ -0,0 +1,89 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestMarketEmptyWindow(t *testing.T) { + from, to := marketWindow(MarketView{}, 0, 200) + if from != 0 || to != 0 { + t.Fatalf("empty window %d..%d", from, to) + } +} + +func TestMarketDisabledAlert(t *testing.T) { + cs := sampleCandles(2) + v := MarketView{} + alerts := []PriceAlertLine{{ID: "a", Price: cs[1].Close + 1}} + disabled, fired := false, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Price"}, Alerts: &alerts, + OnAlert: func(PriceAlertLine, Candle) { fired++ }}) + }) + }, 500, 320) + disabled = true + next := cs[1] + next.Time = next.Time.Add(3600e9) + next.Close += 2 + next.High = max(next.High, next.Close) + cs = append(cs, next) + tt.Frame() + if fired != 0 { + t.Fatalf("disabled alert fired %d times", fired) + } +} + +func TestMarketDisabledLayoutCopy(t *testing.T) { + state := MultiChartState{Count: 2} + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(true).Children(func() { + MultiChartLayout(c, &state, MultiChartLayoutOptions{}, func(_ int, cell *ChartCell) { + cell.Symbol = "changed" + }) + }) + }, 600, 600) + if state.Cells[0].Symbol != "" || state.Cells[1].Symbol != "" { + t.Fatalf("disabled layout wrote cells: %+v", state.Cells) + } +} + +func TestMarketDisabledParamsCopy(t *testing.T) { + var params []float64 + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(true).Children(func() { IndicatorParamsField(c, IndicatorMA, ¶ms) }) + }, 500, 100) + if params != nil { + t.Fatalf("disabled field initialized caller parameters: %v", params) + } +} + +func TestMarketInvalidFiniteData(t *testing.T) { + for _, v := range []float64{math.Inf(1), math.Inf(-1), math.NaN()} { + func() { + defer func() { + if recover() == nil { + t.Errorf("nonfinite candle accepted: %v", v) + } + }() + cs := sampleCandles(1) + cs[0].High = v + marketCheckCandles(cs) + }() + } +} + +func TestMarketLockedUndo(t *testing.T) { + d := DrawingTools{Items: []Drawing{{Tool: ToolHorizontal}}} + d.Clear() + d.Locked = true + if d.Undo() || len(d.Items) != 0 { + t.Fatal("locked drawings accepted undo") + } +} diff --git a/market_review_regression_test.go b/market_review_regression_test.go new file mode 100644 index 0000000..04ddf07 --- /dev/null +++ b/market_review_regression_test.go @@ -0,0 +1,192 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "image" + "math" + "testing" + "time" +) + +func TestMarketSyncRange(t *testing.T) { + cs := sampleCandles(300) + short := cs[:200] + s := ChartSync{Count: 40, End: cs[249].Time} + var a, b MarketView + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Children(func() { + CandlestickChart(c, cs, &a, CandlestickOptions{Frame: ChartFrame{Label: "A", Height: 100}, Sync: &s, SyncID: "a"}) + CandlestickChart(c, short, &b, CandlestickOptions{Frame: ChartFrame{Label: "B", Height: 100}, Sync: &s, SyncID: "b"}) + }) + }, 640, 400) + t.Logf("A=%+v B=%+v sync.End=%v", a, b, s.End) + if a.End != 250 || s.End != cs[249].Time { + t.Error("B erased A's explicit historical range") + } +} +func TestMarketPaneKeyboardGeometry(t *testing.T) { + cs := sampleCandles(300) + v := MarketView{Count: 40} + panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorOBV}}} + var r IndicatorPaneResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r = IndicatorPane(c, cs, &v, &panes, IndicatorPaneOptions{Label: "BTC", Height: 220}) + }, 640, 300) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyHome) + tt.Frame() + from, to := marketWindow(v, 300, 192) + t.Logf("Point=%d visible=%d..%d view=%+v", r.Point, from, to, v) + if r.Point < from || r.Point >= to { + t.Error("focused readout has no visible crosshair") + } +} +func TestMarketAlertsWithDrawingTools(t *testing.T) { + cs := sampleCandles(30) + v := MarketView{} + a := []PriceAlertLine{{ID: "a", Price: 100}} + d := DrawingTools{} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "A"}, Alerts: &a, Drawings: &d}) + }, 640, 300) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyPageDown) + tt.Key(ui.Alt, ui.KeyUp) + t.Logf("alert=%v", a[0].Price) + if a[0].Price != 100.01 { + t.Error("alert keyboard adjustment inaccessible with idle drawing tools") + } +} +func TestMarketDrawingSwitchCancelsPending(t *testing.T) { + cs := sampleCandles(30) + v := MarketView{} + d := DrawingTools{Tool: ToolTrendLine} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Children(func() { + DrawingToolbar(c, &d, DrawingToolbarOptions{}) + CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "A"}, Drawings: &d}).Element.Grow(1) + }) + }, 640, 450) + tt.ClickAt(250, 100) + if d.pending == nil { + t.Fatal("setup missing pending point") + } + tt.Key(ui.Alt, ui.Key1) + t.Logf("Tool=%d pending=%+v", d.Tool, d.pending) + before := v + tt.Press(300, 150) + tt.Move(400, 150) + tt.Release(400, 150) + t.Logf("before=%+v after=%+v pending=%+v", before, v, d.pending) + if d.Tool == ToolSelect && d.pending != nil { + t.Error("Select retains previous tool preview and captures pan input") + } +} +func TestMarketCountdownDST(t *testing.T) { + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 3, 8, 23, 30, 0, 0, loc) + close := Interval("1h").Close(now, loc) + t.Logf("now=%s closes=%s", now, close) + if close.Hour() != 0 { + t.Error("hourly candle closes off the wall-clock hour") + } +} +func TestMarketPaneResizeGeometry(t *testing.T) { + cs := sampleCandles(300) + v := MarketView{Count: 40} + p := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorMACD}, {Kind: IndicatorRSI}}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + IndicatorPane(c, cs, &v, &p, IndicatorPaneOptions{Label: "BTC", Height: 320}) + }, 640, 400) + for i := 0; i < 12; i++ { + tt.Key(0, ui.KeyTab) + if tt.Focused("Resize MACD(12,26,9) and RSI(14)") { + break + } + } + t.Logf("texts=%q", tt.Texts()) + tt.Key(0, ui.KeyHome) + tt.Frame() + top, _ := tt.Find("MACD(12,26,9)") + plot, _ := tt.Find("BTC MACD(12,26,9)") + t.Logf("sizes=%v top=%+v plot=%+v", p.Sizes, top, plot) + if plot.H == 0 { + t.Error("resizing to advertised minimum completely hides the chart") + } +} +func TestMarketMissingComparisonCache(t *testing.T) { + cs := sampleCandles(100000) + v := MarketView{} + s := []CompareSymbol{{Name: "missing", Closes: make([]float64, len(cs))}} + for i := range s[0].Closes { + s[0].Closes[i] = math.NaN() + } + var r ChartResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r = CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "A"}, Compare: &s}) + }, 640, 320) + memo := ui.Local(r.Element, "mujica.memo.compare.missing", func() chartMemoEntry { return chartMemoEntry{} }) + before := chartID(memo.val.([]float64)) + tt.Frame() + memo = ui.Local(r.Element, "mujica.memo.compare.missing", func() chartMemoEntry { return chartMemoEntry{} }) + after := chartID(memo.val.([]float64)) + t.Logf("before=%s after=%s", before, after) + if before != after { + t.Error("unchanged missing comparison allocated and recomputed 100000 values") + } +} + +func TestMarketDailyAxisLocale(t *testing.T) { + candles := sampleCandles(30) + for i := range candles { + candles[i].Time = candles[0].Time.AddDate(0, 0, i) + } + render := func(locale Locale) *image.RGBA { + view := MarketView{} + return ui.Render(func(c *ui.Context) { + Use(c, Settings{Locale: locale}) + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 240}}) + }, 640, 320, 1) + } + en, zh := render(EnUS), render(ZhCN) + for y := 214; y < 240; y++ { + for x := 60; x < 620; x++ { + if en.RGBAAt(x, y) != zh.RGBAAt(x, y) { + return + } + } + } + t.Fatal("Chinese daily time axis retained English labels") +} + +func TestMarketSyncDifferentIntervals(t *testing.T) { + cs := sampleCandles(300) + var slower []Candle + for i := 0; i < len(cs); i += 2 { + slower = append(slower, cs[i]) + } + s := ChartSync{Count: 40, End: cs[199].Time} + var a, b MarketView + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Children(func() { + CandlestickChart(c, cs, &a, CandlestickOptions{Frame: ChartFrame{Label: "A", Height: 100}, Sync: &s, SyncID: "a"}) + CandlestickChart(c, slower, &b, CandlestickOptions{Frame: ChartFrame{Label: "B", Height: 100}, Sync: &s, SyncID: "b"}) + }) + }, 640, 400) + tt.Frame() + from, to := marketWindow(b, len(slower), 200) + if b.Count != 20 || slower[from].Time != cs[160].Time || slower[to-1].Time != cs[198].Time || s.End != cs[199].Time { + t.Fatalf("intervals drifted: a=%+v b=%+v sync=%+v", a, b, s) + } +} diff --git a/market_states_test.go b/market_states_test.go new file mode 100644 index 0000000..90f3d53 --- /dev/null +++ b/market_states_test.go @@ -0,0 +1,115 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "mujicaui/theme" + "testing" +) + +func TestMarketChartsStatesDisabledAndBoundaries(t *testing.T) { + for _, name := range []string{"Candlestick", "OHLC", "HeikinAshi", "Quote", "Volume", "Depth", "Profile", "Overlay", "Compare", "Alerts", "Drawings"} { + t.Run(name, func(t *testing.T) { + cs := sampleCandles(30) + v := MarketView{Count: 30} + raw := false + bids, asks := sampleBook() + bins := VolumeProfileOf(cs, 12) + quotes, _, _ := samplePoints() + symbols := []CompareSymbol{{Name: "Other", Closes: closesOf(cs)}} + alerts := []PriceAlertLine{{ID: "a", Price: 100}} + drawings := DrawingTools{} + status := DataReady + disabled, empty := false, false + var r ChartResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: theme.Compact}) + ui.Column(c).Disabled(disabled).Width(600).Children(func() { + candles := cs + if empty { + candles = nil + } + frame := ChartFrame{Label: "Market", Height: 180, Status: status, Error: "Archive unavailable"} + opts := CandlestickOptions{Frame: frame} + switch name { + case "OHLC": + r = OHLCChart(c, candles, &v, opts) + case "HeikinAshi": + r = HeikinAshiChart(c, candles, &v, &raw, opts) + case "Quote": + q := quotes + if empty { + q = nil + } + r = LineQuoteChart(c, q, LineQuoteChartOptions{Frame: frame, PrevClose: 100}) + case "Volume": + r = VolumeChart(c, candles, &v, VolumeChartOptions{Frame: frame}) + case "Depth": + b, a := bids, asks + if empty { + b = nil + a = nil + } + r = DepthChart(c, b, a, DepthChartOptions{Frame: frame}) + case "Profile": + b := bins + if empty { + b = nil + } + r = VolumeProfile(c, b, VolumeProfileOptions{Frame: frame}) + default: + if name == "Overlay" { + opts.Overlays = []IndicatorOverlay{{Kind: IndicatorMA}} + } + if name == "Compare" { + opts.Compare = &symbols + } + if name == "Alerts" { + opts.Alerts = &alerts + } + if name == "Drawings" { + opts.Drawings = &drawings + } + r = CandlestickChart(c, candles, &v, opts) + } + }) + }, 620, 350) + for range 10 { + if tt.Focused("Market") { + break + } + tt.Key(0, ui.KeyTab) + } + tt.Key(0, ui.KeyHome) + tt.Key(0, ui.KeyRight) + if r.Point < 0 || len(tt.Announcements()) == 0 { + t.Fatalf("keyboard path point %d", r.Point) + } + tt.Key(0, ui.KeyHome) + first := r.Point + tt.Key(0, ui.KeyLeft) + if name != "Depth" && name != "Profile" && r.Point != first { + t.Fatalf("left boundary %d", r.Point) + } + disabled = true + before := v + tt.Frame() + tt.Key(0, ui.KeyEqual) + tt.Scroll(200, 120, 0, 20) + tt.ClickAt(200, 120) + if v != before { + t.Fatalf("disabled window changed: %+v", v) + } + disabled = false + tt.Frame() + height := r.Element.Bounds().H + for _, st := range []DataStatus{DataLoading, DataFailed, DataReady} { + status = st + empty = st == DataReady + tt.Frame() + if r.Point != -1 || r.Element.Bounds().H != height { + t.Fatalf("state %v: point %d height %v want %v", st, r.Point, r.Element.Bounds().H, height) + } + } + }) + } +} diff --git a/messages_mktchart.go b/messages_mktchart.go index 3930344..61c3fef 100644 --- a/messages_mktchart.go +++ b/messages_mktchart.go @@ -2,11 +2,12 @@ package mujica func init() { addMessages(map[string][2]string{ + "candlestick_chart.daily_time": {"Jan 2", "1月2日"}, "candlestick_chart.ohlc": {"O %s H %s L %s C %s", "开 %s 高 %s 低 %s 收 %s"}, "candlestick_chart.volume": {"Vol", "量"}, - "heikin_ashi.smoothed": {"Heikin-Ashi", "平均 K 线"}, - "heikin_ashi.raw": {"Candles", "原 K 线"}, - "heikin_ashi.switch": {"Candle style", "K 线样式"}, + "heikin_ashi_chart.smoothed": {"Heikin-Ashi", "平均 K 线"}, + "heikin_ashi_chart.raw": {"Candles", "原 K 线"}, + "heikin_ashi_chart.switch": {"Candle style", "K 线样式"}, "compare_symbol.label": {"Compared symbols", "对比品种"}, "compare_symbol.remove": {"Remove %s", "移除 %s"}, "price_alert_line.tag": {"Alert %s", "预警 %s"}, @@ -69,6 +70,7 @@ func init() { "indicator_selector.add": {"Add", "添加"}, "indicator_selector.add_named": {"Add %s", "添加 %s"}, "indicator_selector.none": {"No indicator matches", "没有匹配的指标"}, + "drawing_tools.selected": {"Drawing %d selected", "已选择画线 %d"}, "drawing_tools.note": {"Note", "标注"}, }) } diff --git a/multi_chart_layout.go b/multi_chart_layout.go index 1543892..977b422 100644 --- a/multi_chart_layout.go +++ b/multi_chart_layout.go @@ -10,18 +10,18 @@ import ( "mujicaui/theme" ) -// ChartCell is one chart of a MultiChartLayout: its symbol, period and view. +// ChartCell holds one chart's symbol, interval, and window. type ChartCell struct { Symbol string Interval Interval View MarketView } -// MultiChartState is the caller-held grid: how many cells show, all six cells' settings, and the active one. +// MultiChartState preserves six cells across layout changes. type MultiChartState struct { // Count is 1, 2, 4 or 6; zero is 1. Count int - // Cells keep their settings while hidden, so changing the layout loses nothing. + // Cells preserve their settings while hidden. Cells [6]ChartCell Active int } @@ -32,11 +32,19 @@ type MultiChartLayoutOptions struct { Height float32 } -// MultiChartLayout shows 1, 2, 4 or 6 charts in a grid, each built by cell with its own settings. +// MultiChartLayout builds 1, 2, 4, or 6 independent charts. func MultiChartLayout(c *ui.Context, state *MultiChartState, opts MultiChartLayoutOptions, cell func(i int, cell *ChartCell)) ChoiceResult { if state == nil || cell == nil { panic("mujica: MultiChartLayout needs a state and a cell builder") } + root := ui.Column(c).Gap(Density(c).Unit() * 2).FillWidth() + if root.IsDisabled() { + copy := *state + state = © + } + if opts.Height < 0 { + panic("mujica: MultiChartLayout Height must be non-negative") + } if state.Count == 0 { state.Count = 1 } @@ -54,7 +62,6 @@ func MultiChartLayout(c *ui.Context, state *MultiChartState, opts MultiChartLayo h = 480 } changed := false - root := ui.Column(c).Gap(u * 2).FillWidth() root.Children(func() { sel := []int{state.Count} var items []ToggleItem[int] @@ -68,6 +75,7 @@ func MultiChartLayout(c *ui.Context, state *MultiChartState, opts MultiChartLayo slog.Debug("mujica: multi_chart_layout count", "count", state.Count) } }) + cols = map[int]int{1: 1, 2: 2, 4: 2, 6: 3}[state.Count] rows := (state.Count + cols - 1) / cols grid := ui.Box(c).Grid().Columns(cols).Gap(u * 2).Height(h).FillWidth() grid.Children(func() { @@ -81,7 +89,7 @@ func MultiChartLayout(c *ui.Context, state *MultiChartState, opts MultiChartLayo } else { box.Border(theme.BorderWidth, k.Border) } - if box.FocusWithin() || box.Clicked() { + if !box.IsDisabled() && (box.FocusWithin() || box.Clicked()) { if state.Active != i { state.Active, changed = i, true } diff --git a/multi_chart_layout_test.go b/multi_chart_layout_test.go new file mode 100644 index 0000000..9a85df6 --- /dev/null +++ b/multi_chart_layout_test.go @@ -0,0 +1,64 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" +) + +func TestMultiChartLayoutKeepsCellsAndDisabled(t *testing.T) { + state := MultiChartState{Count: 6, Active: 5} + for i := range state.Cells { + state.Cells[i] = ChartCell{Symbol: "BTC", Interval: "1h", View: MarketView{Count: 20 + i}} + } + disabled := false + built := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + built = 0 + ui.Column(c).Disabled(disabled).Children(func() { + MultiChartLayout(c, &state, MultiChartLayoutOptions{Height: 200}, func(_ int, cell *ChartCell) { built++; ui.Text(c, cell.Symbol) }) + }) + }, 700, 300) + if built != 6 { + t.Fatalf("built %d", built) + } + if err := tt.Click("6 charts"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeySpace) + if state.Count != 4 || state.Active != 3 || built != 4 { + t.Fatalf("layout %+v built %d", state, built) + } + if state.Cells[5].View.Count != 25 { + t.Fatal("hidden state lost") + } + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeySpace) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeySpace) + if state.Count != 6 || state.Cells[5].View.Count != 25 { + t.Fatal("restored state lost") + } + disabled = true + tt.Frame() + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeySpace) + if state.Count != 6 { + t.Fatal("disabled layout changed") + } +} +func TestMultiChartLayoutSnapshot(t *testing.T) { + state := MultiChartState{Count: 4} + snapshot(t, "multi_chart_layout", 640, 360, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + MultiChartLayout(c, &state, MultiChartLayoutOptions{Height: 280}, func(i int, cell *ChartCell) { ui.Text(c, "BTC · 1h") }) + }) + }) +} diff --git a/ohlc_chart_test.go b/ohlc_chart_test.go new file mode 100644 index 0000000..8bd948f --- /dev/null +++ b/ohlc_chart_test.go @@ -0,0 +1,44 @@ +package mujica + +import ( + "bytes" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestOHLCChartSharesDataAndDiffersInLook(t *testing.T) { + candles := sampleCandles(60) + view := MarketView{Count: 40} + ohlc := true + var res ChartResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ohlc { + res = OHLCChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "Bars"}}) + } else { + res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "Bars"}}) + } + }, 600, 320) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + if res.Point != 59 || mktState0(res).drawn != 40 { + t.Fatalf("point %d drawn %d", res.Point, mktState0(res).drawn) + } + a := encode(t, tt.Image()) + ohlc = false + tt.Frame() + if bytes.Equal(a, encode(t, tt.Image())) { + t.Fatal("OHLC bars look the same as candles") + } +} + +func TestOHLCChartSnapshot(t *testing.T) { + candles := sampleCandles(60) + view := MarketView{Count: 50} + snapshot(t, "ohlc_chart", 640, 300, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + OHLCChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "Bars", Height: 240}}) + }) + }) +} diff --git a/overlay_layers.go b/overlay_layers.go index 6b9c419..381fc35 100644 --- a/overlay_layers.go +++ b/overlay_layers.go @@ -53,7 +53,19 @@ func overlayEscape(c *ui.Context, key any) bool { // The root is around every focus, so it takes Escape before MyGo's overlays. byFocus := c.Root().Shortcut(0, ui.KeyEscape) byWindow := c.Shortcut(0, ui.KeyEscape) - return byFocus || byWindow + if byFocus || byWindow { + s := ui.Local(c.Root(), overlayLayersKey{}, func() overlayLayers { return overlayLayers{} }) + for i, k := range s.keys { + if k == key { + s.keys = append(s.keys[:i], s.keys[i+1:]...) + s.seen = append(s.seen[:i], s.seen[i+1:]...) + break + } + } + c.Invalidate() + return true + } + return false } // overlaySiteState follows the focus of a layer after it closes. diff --git a/price_alert_line_test.go b/price_alert_line_test.go new file mode 100644 index 0000000..d138d26 --- /dev/null +++ b/price_alert_line_test.go @@ -0,0 +1,56 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestPriceAlertLineDragAndTrigger(t *testing.T) { + candles := sampleCandles(60) + view := MarketView{Count: 60} + alerts := []PriceAlertLine{{ID: "a", Price: 100, Label: "Buy"}} + var fired []string + var res ChartResult + _ = res + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}, Alerts: &alerts, + OnAlert: func(a PriceAlertLine, last Candle) { fired = append(fired, a.ID) }}) + }, 800, 400) + if !tt.HasText("BTC") { + t.Fatal("not built") + } + // Drag the line from its own y, as painted. + st := mktState0(res) + plot := st.plot + y := st.ys.Map(100) + tt.Press(plot.X+100, y) + tt.Move(plot.X+100, y-40) + tt.Release(plot.X+100, y-40) + if alerts[0].Price <= 100 || view.End != 0 { + t.Fatalf("drag did not move the alert up, or panned: %+v %+v", alerts, view) + } + // A new close crossing the alert fires it once. + alerts[0].Price = candles[59].Close + 0.5 + next := candles[59] + next.Time = next.Time.Add(3600e9) + next.Open, next.Close, next.High, next.Low = next.Close, next.Close+1, next.Close+1.2, next.Close-0.1 + candles = append(candles, next) + tt.Frame() + tt.Frame() + if len(fired) != 1 || fired[0] != "a" { + t.Fatalf("fired %v", fired) + } +} + +func TestPriceAlertLineSnapshot(t *testing.T) { + candles := sampleCandles(60) + view := MarketView{Count: 60} + alerts := []PriceAlertLine{{ID: "a", Price: 104, Label: "Take profit"}, {ID: "b", Price: 96}} + snapshot(t, "price_alert_line", 640, 320, func(c *ui.Context) { + ui.Column(c).Padding(12).Children(func() { + CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 260}, Alerts: &alerts}) + }) + }) +} diff --git a/testdata/snapshots/darwin/candle_countdown-dark.png b/testdata/snapshots/darwin/candle_countdown-dark.png new file mode 100644 index 0000000..0670f01 Binary files /dev/null and b/testdata/snapshots/darwin/candle_countdown-dark.png differ diff --git a/testdata/snapshots/darwin/candle_countdown-light.png b/testdata/snapshots/darwin/candle_countdown-light.png new file mode 100644 index 0000000..661c931 Binary files /dev/null and b/testdata/snapshots/darwin/candle_countdown-light.png differ diff --git a/testdata/snapshots/darwin/chart_sync-dark.png b/testdata/snapshots/darwin/chart_sync-dark.png new file mode 100644 index 0000000..9f518b5 Binary files /dev/null and b/testdata/snapshots/darwin/chart_sync-dark.png differ diff --git a/testdata/snapshots/darwin/chart_sync-light.png b/testdata/snapshots/darwin/chart_sync-light.png new file mode 100644 index 0000000..d996b23 Binary files /dev/null and b/testdata/snapshots/darwin/chart_sync-light.png differ diff --git a/testdata/snapshots/darwin/chart_type_switcher-dark.png b/testdata/snapshots/darwin/chart_type_switcher-dark.png new file mode 100644 index 0000000..08bb4a8 Binary files /dev/null and b/testdata/snapshots/darwin/chart_type_switcher-dark.png differ diff --git a/testdata/snapshots/darwin/chart_type_switcher-light.png b/testdata/snapshots/darwin/chart_type_switcher-light.png new file mode 100644 index 0000000..6543cec Binary files /dev/null and b/testdata/snapshots/darwin/chart_type_switcher-light.png differ diff --git a/testdata/snapshots/darwin/compare_symbol-dark.png b/testdata/snapshots/darwin/compare_symbol-dark.png new file mode 100644 index 0000000..676d1c5 Binary files /dev/null and b/testdata/snapshots/darwin/compare_symbol-dark.png differ diff --git a/testdata/snapshots/darwin/compare_symbol-light.png b/testdata/snapshots/darwin/compare_symbol-light.png new file mode 100644 index 0000000..d196513 Binary files /dev/null and b/testdata/snapshots/darwin/compare_symbol-light.png differ diff --git a/testdata/snapshots/darwin/drawing_toolbar-dark.png b/testdata/snapshots/darwin/drawing_toolbar-dark.png new file mode 100644 index 0000000..6ace567 Binary files /dev/null and b/testdata/snapshots/darwin/drawing_toolbar-dark.png differ diff --git a/testdata/snapshots/darwin/drawing_toolbar-light.png b/testdata/snapshots/darwin/drawing_toolbar-light.png new file mode 100644 index 0000000..02168e9 Binary files /dev/null and b/testdata/snapshots/darwin/drawing_toolbar-light.png differ diff --git a/testdata/snapshots/darwin/drawing_tools-dark.png b/testdata/snapshots/darwin/drawing_tools-dark.png new file mode 100644 index 0000000..3651947 Binary files /dev/null and b/testdata/snapshots/darwin/drawing_tools-dark.png differ diff --git a/testdata/snapshots/darwin/drawing_tools-light.png b/testdata/snapshots/darwin/drawing_tools-light.png new file mode 100644 index 0000000..9d7aad2 Binary files /dev/null and b/testdata/snapshots/darwin/drawing_tools-light.png differ diff --git a/testdata/snapshots/darwin/heikin_ashi_chart-dark.png b/testdata/snapshots/darwin/heikin_ashi_chart-dark.png new file mode 100644 index 0000000..d8d8a54 Binary files /dev/null and b/testdata/snapshots/darwin/heikin_ashi_chart-dark.png differ diff --git a/testdata/snapshots/darwin/heikin_ashi_chart-light.png b/testdata/snapshots/darwin/heikin_ashi_chart-light.png new file mode 100644 index 0000000..6087dec Binary files /dev/null and b/testdata/snapshots/darwin/heikin_ashi_chart-light.png differ diff --git a/testdata/snapshots/darwin/indicator_overlay-dark.png b/testdata/snapshots/darwin/indicator_overlay-dark.png new file mode 100644 index 0000000..00070ce Binary files /dev/null and b/testdata/snapshots/darwin/indicator_overlay-dark.png differ diff --git a/testdata/snapshots/darwin/indicator_overlay-light.png b/testdata/snapshots/darwin/indicator_overlay-light.png new file mode 100644 index 0000000..98f7c6a Binary files /dev/null and b/testdata/snapshots/darwin/indicator_overlay-light.png differ diff --git a/testdata/snapshots/darwin/indicator_pane-dark.png b/testdata/snapshots/darwin/indicator_pane-dark.png index 5e3cda3..2600ec0 100644 Binary files a/testdata/snapshots/darwin/indicator_pane-dark.png and b/testdata/snapshots/darwin/indicator_pane-dark.png differ diff --git a/testdata/snapshots/darwin/indicator_pane-light.png b/testdata/snapshots/darwin/indicator_pane-light.png index f4df8bd..1e0c7a5 100644 Binary files a/testdata/snapshots/darwin/indicator_pane-light.png and b/testdata/snapshots/darwin/indicator_pane-light.png differ diff --git a/testdata/snapshots/darwin/indicator_selector-dark.png b/testdata/snapshots/darwin/indicator_selector-dark.png new file mode 100644 index 0000000..b7bd63b Binary files /dev/null and b/testdata/snapshots/darwin/indicator_selector-dark.png differ diff --git a/testdata/snapshots/darwin/indicator_selector-light.png b/testdata/snapshots/darwin/indicator_selector-light.png new file mode 100644 index 0000000..dcdf642 Binary files /dev/null and b/testdata/snapshots/darwin/indicator_selector-light.png differ diff --git a/testdata/snapshots/darwin/interval_selector-dark.png b/testdata/snapshots/darwin/interval_selector-dark.png new file mode 100644 index 0000000..1c18b0d Binary files /dev/null and b/testdata/snapshots/darwin/interval_selector-dark.png differ diff --git a/testdata/snapshots/darwin/interval_selector-light.png b/testdata/snapshots/darwin/interval_selector-light.png new file mode 100644 index 0000000..16be480 Binary files /dev/null and b/testdata/snapshots/darwin/interval_selector-light.png differ diff --git a/testdata/snapshots/darwin/line_quote_chart-dark.png b/testdata/snapshots/darwin/line_quote_chart-dark.png index a2e8154..093d1e0 100644 Binary files a/testdata/snapshots/darwin/line_quote_chart-dark.png and b/testdata/snapshots/darwin/line_quote_chart-dark.png differ diff --git a/testdata/snapshots/darwin/line_quote_chart-light.png b/testdata/snapshots/darwin/line_quote_chart-light.png index 04f7981..ecd78a3 100644 Binary files a/testdata/snapshots/darwin/line_quote_chart-light.png and b/testdata/snapshots/darwin/line_quote_chart-light.png differ diff --git a/testdata/snapshots/darwin/multi_chart_layout-dark.png b/testdata/snapshots/darwin/multi_chart_layout-dark.png new file mode 100644 index 0000000..0c574e0 Binary files /dev/null and b/testdata/snapshots/darwin/multi_chart_layout-dark.png differ diff --git a/testdata/snapshots/darwin/multi_chart_layout-light.png b/testdata/snapshots/darwin/multi_chart_layout-light.png new file mode 100644 index 0000000..6325835 Binary files /dev/null and b/testdata/snapshots/darwin/multi_chart_layout-light.png differ diff --git a/testdata/snapshots/darwin/ohlc_chart-dark.png b/testdata/snapshots/darwin/ohlc_chart-dark.png new file mode 100644 index 0000000..472d4d0 Binary files /dev/null and b/testdata/snapshots/darwin/ohlc_chart-dark.png differ diff --git a/testdata/snapshots/darwin/ohlc_chart-light.png b/testdata/snapshots/darwin/ohlc_chart-light.png new file mode 100644 index 0000000..7ffe790 Binary files /dev/null and b/testdata/snapshots/darwin/ohlc_chart-light.png differ diff --git a/testdata/snapshots/darwin/price_alert_line-dark.png b/testdata/snapshots/darwin/price_alert_line-dark.png new file mode 100644 index 0000000..a356e5e Binary files /dev/null and b/testdata/snapshots/darwin/price_alert_line-dark.png differ diff --git a/testdata/snapshots/darwin/price_alert_line-light.png b/testdata/snapshots/darwin/price_alert_line-light.png new file mode 100644 index 0000000..57adfad Binary files /dev/null and b/testdata/snapshots/darwin/price_alert_line-light.png differ diff --git a/testdata/snapshots/darwin/time_range_selector-dark.png b/testdata/snapshots/darwin/time_range_selector-dark.png new file mode 100644 index 0000000..05ff743 Binary files /dev/null and b/testdata/snapshots/darwin/time_range_selector-dark.png differ diff --git a/testdata/snapshots/darwin/time_range_selector-light.png b/testdata/snapshots/darwin/time_range_selector-light.png new file mode 100644 index 0000000..05275da Binary files /dev/null and b/testdata/snapshots/darwin/time_range_selector-light.png differ diff --git a/testdata/snapshots/darwin/volume_profile-dark.png b/testdata/snapshots/darwin/volume_profile-dark.png index 8881494..46e1626 100644 Binary files a/testdata/snapshots/darwin/volume_profile-dark.png and b/testdata/snapshots/darwin/volume_profile-dark.png differ diff --git a/testdata/snapshots/darwin/volume_profile-light.png b/testdata/snapshots/darwin/volume_profile-light.png index a816942..b99de04 100644 Binary files a/testdata/snapshots/darwin/volume_profile-light.png and b/testdata/snapshots/darwin/volume_profile-light.png differ diff --git a/time_range_selector.go b/time_range_selector.go index 01e7270..127a6e0 100644 --- a/time_range_selector.go +++ b/time_range_selector.go @@ -8,7 +8,7 @@ import ( "github.com/egoist/mygo/ui" ) -// RangePreset names a quick time range. The zero value is Range1D. +// RangePreset names a time range; zero selects Range1D. type RangePreset int const ( @@ -24,14 +24,14 @@ const ( var rangePresetNames = []string{"1D", "5D", "1M", "3M", "YTD", "1Y", "5Y"} -// MarketRange is a chosen range: a preset, or Custom with its dates. +// MarketRange stores a preset or custom date range. type MarketRange struct { Preset RangePreset // Custom holds the dates when Preset is RangeCustom. Custom DateRange } -// Bounds returns the range's start and end at now, in loc; custom ends include their last day. +// Bounds returns local boundaries, including the custom end date. func (r MarketRange) Bounds(now time.Time, loc *time.Location) (from, to time.Time) { now = now.In(loc) switch r.Preset { @@ -66,7 +66,7 @@ type TimeRangeSelectorOptions struct { Today Date } -// TimeRangeSelector chooses a preset range or a custom one; the presets are one Tab stop moved with the arrows. +// TimeRangeSelector chooses presets and custom dates through keyboard-accessible controls. func TimeRangeSelector(c *ui.Context, value *MarketRange, opts TimeRangeSelectorOptions) ChoiceResult { if value == nil { panic("mujica: TimeRangeSelector needs a value") diff --git a/time_range_selector_test.go b/time_range_selector_test.go new file mode 100644 index 0000000..714dd27 --- /dev/null +++ b/time_range_selector_test.go @@ -0,0 +1,48 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "testing" + "time" +) + +func TestTimeRangeSelectorPresetsCustomAndDisabled(t *testing.T) { + value := MarketRange{} + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TimeRangeSelector(c, &value, TimeRangeSelectorOptions{Disabled: disabled, Today: Date{2026, time.October, 5}}) + }, 850, 160) + tt.Key(0, ui.KeyTab) + for _, want := range []RangePreset{Range5D, Range1M, Range3M, RangeYTD, Range1Y, Range5Y, RangeCustom} { + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeySpace) + if value.Preset != want { + t.Fatalf("preset %v", value.Preset) + } + } + if value.Custom.Start != (Date{2026, time.September, 5}) || value.Custom.End != (Date{2026, time.October, 5}) { + t.Fatalf("custom %+v", value.Custom) + } + now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) + for p := Range1D; p <= RangeCustom; p++ { + r := value + r.Preset = p + from, to := r.Bounds(now, time.UTC) + if !from.Before(to) { + t.Fatalf("range %v: %v %v", p, from, to) + } + } + disabled = true + tt.Frame() + tt.Key(0, ui.KeyLeft) + if value.Preset != RangeCustom { + t.Fatal("disabled preset changed") + } +} +func TestTimeRangeSelectorSnapshot(t *testing.T) { + value := MarketRange{Preset: Range1M} + snapshot(t, "time_range_selector", 680, 90, func(c *ui.Context) { + ui.Row(c).Padding(12).Children(func() { TimeRangeSelector(c, &value, TimeRangeSelectorOptions{}) }) + }) +} diff --git a/volume_chart.go b/volume_chart.go index 6885fb2..60acd2f 100644 --- a/volume_chart.go +++ b/volume_chart.go @@ -18,7 +18,7 @@ type VolumeChartOptions struct { SyncID string } -// VolumeChart draws the volume of the view's window as bars, hollow on rises and filled on falls; give it the price chart's view to share the time axis. +// VolumeChart paints visible volume bars using the shared MarketView. func VolumeChart(c *ui.Context, candles []Candle, view *MarketView, opts VolumeChartOptions) ChartResult { if view == nil { panic("mujica: VolumeChart needs a view") @@ -31,6 +31,11 @@ func VolumeChart(c *ui.Context, candles []Candle, view *MarketView, opts VolumeC } ch := newChart(c, opts.Frame) chartMemo(ch, "check", chartID(candles), func() bool { marketCheckCandles(candles); return true }) + if ch.res.Element.IsDisabled() { + copy := *view + view = © + opts.Sync = nil + } m := &mktCore{c: c, k: Tokens(c), ch: ch, candles: candles, view: view} if s := opts.Sync; s != nil { s.load(opts.SyncID, candles, view) diff --git a/volume_profile.go b/volume_profile.go index 2d6a3e1..f5342ad 100644 --- a/volume_profile.go +++ b/volume_profile.go @@ -3,6 +3,7 @@ package mujica import ( "fmt" "math" + "sort" "strings" "github.com/egoist/mygo/ui" @@ -16,7 +17,7 @@ type ProfileBin struct { Low, High, Volume float64 } -// VolumeProfileOf spreads each candle's volume evenly over its low-high range into n equal price bins. +// VolumeProfileOf spreads candle volumes across equal price bins. func VolumeProfileOf(candles []Candle, n int) []ProfileBin { if n < 1 { panic("mujica: VolumeProfileOf needs at least one bin") @@ -24,6 +25,7 @@ func VolumeProfileOf(candles []Candle, n int) []ProfileBin { if len(candles) == 0 { return nil } + marketCheckCandles(candles) lo, hi := math.Inf(1), math.Inf(-1) for _, c := range candles { lo, hi = min(lo, c.Low), max(hi, c.High) @@ -55,13 +57,16 @@ func VolumeProfileOf(candles []Candle, n int) []ProfileBin { return bins } -// ValueArea returns the point of control (the fullest bin) and the bins [lo, hi] around it holding frac of the volume. +// ValueArea finds POC and its contiguous volume fraction. func ValueArea(bins []ProfileBin, frac float64) (poc, lo, hi int) { if len(bins) == 0 || !(frac > 0 && frac <= 1) { panic("mujica: ValueArea needs bins and a fraction in (0, 1]") } var total float64 for i, b := range bins { + if b.Volume < 0 || math.IsNaN(b.Volume) || math.IsInf(b.Volume, 0) { + panic("mujica: profile volumes must be finite and non-negative") + } total += b.Volume if b.Volume > bins[poc].Volume { poc = i @@ -95,24 +100,27 @@ func ValueArea(bins []ProfileBin, frac float64) (poc, lo, hi int) { // VolumeProfileOptions configure a VolumeProfile. type VolumeProfileOptions struct { Frame ChartFrame - // ValueArea is the share of volume in the value area; zero is 0.7. + // ValueArea defaults to 0.7. ValueArea float64 Decimals int } -// VolumeProfile draws volume by price as horizontal bars, marking the point of control and the value area. +// VolumeProfile draws volume distribution, value area, and POC. func VolumeProfile(c *ui.Context, bins []ProfileBin, opts VolumeProfileOptions) ChartResult { frac := opts.ValueArea if frac == 0 { frac = 0.7 } + if !(frac > 0 && frac <= 1) { + panic("mujica: VolumeProfile ValueArea must be in (0, 1]") + } dec := priceDecimals("VolumeProfile", opts.Decimals) ch := newChart(c, opts.Frame) k := Tokens(c) va := chartMemo(ch, "va", [2]any{chartID(bins), frac}, func() [4]float64 { vmax := 0.0 for i, b := range bins { - if !(b.High > b.Low) || b.Volume < 0 || i > 0 && b.Low < bins[i-1].High-1e-9 { + if math.IsNaN(b.Volume) || math.IsInf(b.Volume, 0) || math.IsInf(b.High, 0) || math.IsInf(b.Low, 0) || !(b.High > b.Low) || b.Volume < 0 || i > 0 && b.Low < bins[i-1].High-1e-9 { panic(fmt.Sprintf("mujica: VolumeProfile bin %d is empty, negative or out of order", i)) } vmax = max(vmax, b.Volume) @@ -126,6 +134,7 @@ func VolumeProfile(c *ui.Context, bins []ProfileBin, opts VolumeProfileOptions) poc, vlo, vhi, vmax := int(va[0]), int(va[1]), int(va[2]), va[3] inArea, outArea := k.AccentText, k.TextMuted keys := []chartKey{{Name: msg(c, "volume_profile.value_area"), Color: inArea, Shape: 1}, {Name: msg(c, "volume_profile.other"), Color: outArea, Shape: 4}} + drawn := ui.Local(ch.res.Element, "mujica.profile.drawn", func() int { return 0 }) size := FontSize(c, theme.CaptionSize) geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) { plot := chartInset(r, 60, 26) @@ -145,10 +154,9 @@ func VolumeProfile(c *ui.Context, bins []ProfileBin, opts VolumeProfileOptions) return -1 } v := ys.Invert(y) - for i, b := range bins { - if v >= b.Low && v <= b.High { - return i - } + i := sort.Search(len(bins), func(i int) bool { return bins[i].High >= v }) + if i < len(bins) && v >= bins[i].Low { + return i } return -1 } @@ -174,12 +182,17 @@ func VolumeProfile(c *ui.Context, bins []ProfileBin, opts VolumeProfileOptions) tx, tl = append(tx, xs.Map(t)), append(tl, volumeShort(t, xstep)) } internal.AxisX(p, plot, tx, tl, size, k) - for i, b := range bins { + indices := chartMemo(ch, "profile.indices", [3]any{chartID(bins), plot.Y, plot.H}, func() []int { + return internal.Columns(len(bins), func(i int) float32 { return ys.Map(bins[i].Low) }, func(i int) float64 { return bins[i].Volume }) + }) + *drawn = len(indices) + for _, i := range indices { + b := bins[i] y0, y1 := ys.Map(b.High), ys.Map(b.Low) bar := ui.Rect{X: plot.X, Y: y0 + 1, W: xs.Map(b.Volume) - plot.X, H: max(1, y1-y0-2)} // Value-area bars are filled, the rest outlined. if i >= vlo && i <= vhi { - p.Fill(bar, inArea.Alpha(0.75), 0) + p.Fill(bar, inArea, 0) } else { p.Stroke(bar, outArea, 0, 1) }