WIP components (as built)
No files matched your search
@@ -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).
|
||||
@@ -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
|
||||
@@ -1,7 +1,9 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
@@ -38,23 +40,24 @@ type ChartAnnotationOptions struct {
|
||||
|
||||
// ChartAnnotationPart is notes ready to draw, and to drag.
|
||||
type ChartAnnotationPart struct {
|
||||
c *ui.Context
|
||||
notes []ChartNote
|
||||
opts ChartAnnotationOptions
|
||||
k theme.Tokens
|
||||
size float32
|
||||
}
|
||||
|
||||
// ChartAnnotation marks lines, bands and text on a plot; dragging writes into notes.
|
||||
// ChartAnnotation draws notes and enables optional movement.
|
||||
func ChartAnnotation(c *ui.Context, notes []ChartNote, opts ChartAnnotationOptions) ChartAnnotationPart {
|
||||
if opts.X == nil || opts.Y == nil {
|
||||
panic("mujica: ChartAnnotation needs X and Y axes")
|
||||
}
|
||||
for _, n := range notes {
|
||||
if n.Kind > NoteText || n.Kind == NoteBand && n.End <= n.Value || n.Kind == NoteText && n.Text == "" {
|
||||
if n.Kind < NoteHLine || n.Kind > NoteText || math.IsNaN(n.Value) || math.IsInf(n.Value, 0) || math.IsNaN(n.End) || math.IsInf(n.End, 0) || n.Kind == NoteBand && n.End <= n.Value || n.Kind == NoteText && n.Text == "" {
|
||||
panic("mujica: ChartAnnotation note needs a known kind, a band End above Value, and text")
|
||||
}
|
||||
}
|
||||
return ChartAnnotationPart{notes: notes, opts: opts, k: Tokens(c), size: FontSize(c, theme.CaptionSize)}
|
||||
return ChartAnnotationPart{c: c, notes: notes, opts: opts, k: Tokens(c), size: FontSize(c, theme.CaptionSize)}
|
||||
}
|
||||
|
||||
// Draw paints bands first, then lines and text, inside plot.
|
||||
@@ -105,7 +108,7 @@ func chartDash(p *ui.Painter, x0, y0, x1, y1 float32, col ui.Color) {
|
||||
}
|
||||
}
|
||||
|
||||
// chartNoteLabel is a line's text, or its value when it has none.
|
||||
// chartNoteLabel formats a line's label and value.
|
||||
func chartNoteLabel(n ChartNote, a *ChartAxisPart) string {
|
||||
if n.Text != "" {
|
||||
return n.Text + " " + a.Label(n.Value)
|
||||
@@ -118,8 +121,14 @@ type noteDrag struct {
|
||||
note int
|
||||
}
|
||||
|
||||
// Drag moves the draggable note under the pointer while plot is dragged; r is plot's box.
|
||||
// Drag handles movement; plot uses element coordinates.
|
||||
func (a ChartAnnotationPart) Drag(plotElement *ui.Element, plot ui.Rect) bool {
|
||||
if plotElement.IsDisabled() {
|
||||
return false
|
||||
}
|
||||
if a.keyboard(plotElement) {
|
||||
return true
|
||||
}
|
||||
st := ui.Local(plotElement, "mujica.notes.drag", func() noteDrag { return noteDrag{} })
|
||||
dx, dy, held := plotElement.Dragged()
|
||||
if !held || plotElement.IsDisabled() {
|
||||
@@ -174,3 +183,68 @@ func (a ChartAnnotationPart) Drag(plotElement *ui.Element, plot ui.Rect) bool {
|
||||
slog.Debug("mujica: chart_annotation drag", "note", st.note, "value", n.Value)
|
||||
return true
|
||||
}
|
||||
|
||||
func (a ChartAnnotationPart) keyboard(e *ui.Element) bool {
|
||||
index := ui.Local(e, "mujica.notes.key", func() int { return 0 })
|
||||
dir := 0
|
||||
if e.Shortcut(ui.Alt, ui.KeyPageDown) {
|
||||
dir = 1
|
||||
}
|
||||
if e.Shortcut(ui.Alt, ui.KeyPageUp) {
|
||||
dir = -1
|
||||
}
|
||||
var editable []int
|
||||
for i, n := range a.notes {
|
||||
if n.Draggable {
|
||||
editable = append(editable, i)
|
||||
}
|
||||
}
|
||||
if len(editable) == 0 {
|
||||
return false
|
||||
}
|
||||
*index = (*index + dir + len(editable)) % len(editable)
|
||||
n := &a.notes[editable[*index]]
|
||||
dx, dy := 0.0, 0.0
|
||||
if e.Shortcut(ui.Alt, ui.KeyRight) {
|
||||
dx = 1
|
||||
}
|
||||
if e.Shortcut(ui.Alt, ui.KeyLeft) {
|
||||
dx = -1
|
||||
}
|
||||
if e.Shortcut(ui.Alt, ui.KeyUp) {
|
||||
dy = 1
|
||||
}
|
||||
if e.Shortcut(ui.Alt, ui.KeyDown) {
|
||||
dy = -1
|
||||
}
|
||||
xstep := (a.opts.X.hi - a.opts.X.lo) / 100
|
||||
ystep := (a.opts.Y.hi - a.opts.Y.lo) / 100
|
||||
before := *n
|
||||
switch n.Kind {
|
||||
case NoteHLine:
|
||||
n.Value += dy * ystep
|
||||
case NoteVLine:
|
||||
n.Value += dx * xstep
|
||||
case NoteBand:
|
||||
n.Value, n.End = n.Value+dx*xstep, n.End+dx*xstep
|
||||
case NoteText:
|
||||
n.Value, n.End = n.Value+dx*xstep, n.End+dy*ystep
|
||||
}
|
||||
if dir != 0 || before != *n {
|
||||
key := "chart_annotation.x"
|
||||
value := a.opts.X.Label(n.Value)
|
||||
switch n.Kind {
|
||||
case NoteHLine:
|
||||
key, value = "chart_annotation.y", a.opts.Y.Label(n.Value)
|
||||
case NoteBand:
|
||||
value += "–" + a.opts.X.Label(n.End)
|
||||
case NoteText:
|
||||
value += " · " + fmt.Sprintf(msg(a.c, "chart_annotation.y"), a.opts.Y.Label(n.End))
|
||||
}
|
||||
text := fmt.Sprintf(msg(a.c, "chart_annotation.readout"), editable[*index]+1, fmt.Sprintf(msg(a.c, key), value))
|
||||
e.Description(text)
|
||||
a.c.Announce(text)
|
||||
slog.Debug("mujica: chart_annotation keyboard", "note", editable[*index], "value", n.Value)
|
||||
}
|
||||
return before != *n
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
@@ -20,7 +21,7 @@ func TestChartAnnotationDrag(t *testing.T) {
|
||||
plot := chartInset(ui.Rect{X: b.X, Y: b.Y, W: b.W, H: 240}, 52, 26)
|
||||
yAt := func(v float32) float32 { return plot.Y + plot.H*(1-v/100) }
|
||||
xAt := func(v float32) float32 { return plot.X + plot.W*v/10 }
|
||||
// Drag the Goal line up by a fifth of the plot: 50 becomes 70.
|
||||
// Drag Goal from 50 to 70.
|
||||
tt.Press(xAt(4), yAt(50))
|
||||
tt.Move(xAt(4), yAt(60))
|
||||
tt.Move(xAt(4), yAt(70))
|
||||
@@ -78,3 +79,43 @@ func TestChartAnnotationSnapshot(t *testing.T) {
|
||||
ui.Column(c).Padding(16).Children(func() { partsChart(c, notes, false) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestChartAnnotationKeyboardEditsEveryKind(t *testing.T) {
|
||||
notes := []ChartNote{
|
||||
{Kind: NoteHLine, Value: 50, Draggable: true},
|
||||
{Kind: NoteVLine, Value: 3, Draggable: true},
|
||||
{Kind: NoteBand, Value: 6, End: 8, Draggable: true},
|
||||
{Kind: NoteText, Value: 1, End: 90, Text: "Feast", Draggable: true},
|
||||
}
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult { return partsChart(c, notes, false) }}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Parts")
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
if notes[0].Value != 51 {
|
||||
t.Fatalf("horizontal line %v", notes[0].Value)
|
||||
}
|
||||
if said := tt.Announcements(); len(said) == 0 || !strings.Contains(said[len(said)-1], "y 51") {
|
||||
t.Fatalf("horizontal readout %q", said)
|
||||
}
|
||||
tt.Key(ui.Alt, ui.KeyPageDown)
|
||||
tt.Key(ui.Alt, ui.KeyRight)
|
||||
if notes[1].Value != 3.1 {
|
||||
t.Fatalf("vertical line %v", notes[1].Value)
|
||||
}
|
||||
tt.Key(ui.Alt, ui.KeyPageDown)
|
||||
tt.Key(ui.Alt, ui.KeyRight)
|
||||
if math.Abs(notes[2].Value-6.1) > 1e-9 || notes[2].End-notes[2].Value != 2 {
|
||||
t.Fatalf("band %+v", notes[2])
|
||||
}
|
||||
tt.Key(ui.Alt, ui.KeyPageDown)
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
if notes[3].End != 91 {
|
||||
t.Fatalf("text %+v", notes[3])
|
||||
}
|
||||
h.disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
if notes[3].End != 91 {
|
||||
t.Fatalf("disabled text %+v", notes[3])
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ type ChartAxisKind int
|
||||
|
||||
const (
|
||||
AxisLinear ChartAxisKind = iota
|
||||
// AxisLog maps powers of ten evenly; Min must be above zero.
|
||||
// AxisLog maps powers of ten; Min must be positive.
|
||||
AxisLog
|
||||
// AxisTime maps From..To; values are Unix seconds.
|
||||
AxisTime
|
||||
@@ -45,11 +45,11 @@ type ChartAxisOptions struct {
|
||||
Categories []string
|
||||
// Ticks fixes the tick values; nil picks them.
|
||||
Ticks []float64
|
||||
// Format writes tick and readout labels; nil picks one per kind.
|
||||
// Format overrides tick and readout labels.
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// ChartAxisPart is an axis ready to map values and draw itself in a plot.
|
||||
// ChartAxisPart maps values and draws an axis.
|
||||
type ChartAxisPart struct {
|
||||
opts ChartAxisOptions
|
||||
lo, hi float64
|
||||
@@ -66,7 +66,7 @@ func ChartAxis(c *ui.Context, opts ChartAxisOptions) ChartAxisPart {
|
||||
switch opts.Kind {
|
||||
case AxisLinear, AxisLog:
|
||||
a.lo, a.hi = opts.Min, opts.Max
|
||||
if !(a.hi > a.lo) || opts.Kind == AxisLog && a.lo <= 0 {
|
||||
if !(a.hi > a.lo) || math.IsInf(a.lo, 0) || math.IsInf(a.hi, 0) || opts.Kind == AxisLog && a.lo <= 0 {
|
||||
panic("mujica: ChartAxis needs Min < Max, and Min > 0 for a log axis")
|
||||
}
|
||||
case AxisTime:
|
||||
@@ -82,10 +82,15 @@ func ChartAxis(c *ui.Context, opts ChartAxisOptions) ChartAxisPart {
|
||||
default:
|
||||
panic("mujica: unknown ChartAxisKind")
|
||||
}
|
||||
if opts.Side > SideTop {
|
||||
if opts.Side < SideBottom || opts.Side > SideTop {
|
||||
panic("mujica: unknown ChartSide")
|
||||
}
|
||||
a.ticks = opts.Ticks
|
||||
for i, v := range a.ticks {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) || v < a.lo || v > a.hi || i > 0 && v <= a.ticks[i-1] {
|
||||
panic("mujica: ChartAxis ticks must ascend inside the domain")
|
||||
}
|
||||
}
|
||||
if a.ticks == nil {
|
||||
a.ticks = a.autoTicks()
|
||||
}
|
||||
@@ -99,7 +104,7 @@ func (a *ChartAxisPart) autoTicks() []float64 {
|
||||
var out []float64
|
||||
switch a.opts.Kind {
|
||||
case AxisLog:
|
||||
// Powers of ten, with 2 and 5 between them on short spans.
|
||||
// Short spans include powers of two and five.
|
||||
mants := []float64{1}
|
||||
if a.hi/a.lo < 1000 {
|
||||
mants = []float64{1, 2, 5}
|
||||
@@ -118,9 +123,14 @@ func (a *ChartAxisPart) autoTicks() []float64 {
|
||||
out = append(out, chartSeconds(t))
|
||||
}
|
||||
case AxisCategory:
|
||||
for i := range a.opts.Categories {
|
||||
n := len(a.opts.Categories)
|
||||
step := max(1, (n+63)/64)
|
||||
for i := 0; i < n; i += step {
|
||||
out = append(out, float64(i))
|
||||
}
|
||||
if out[len(out)-1] != float64(n-1) {
|
||||
out = append(out, float64(n-1))
|
||||
}
|
||||
default:
|
||||
lo, hi, step := internal.Nice(a.lo, a.hi, 5)
|
||||
a.step = step
|
||||
@@ -138,7 +148,7 @@ func (a ChartAxisPart) horizontal() bool {
|
||||
return a.opts.Side == SideBottom || a.opts.Side == SideTop
|
||||
}
|
||||
|
||||
// fraction is how far along the axis v lies, 0 to 1.
|
||||
// fraction returns the normalized position along the axis.
|
||||
func (a ChartAxisPart) fraction(v float64) float64 {
|
||||
if a.opts.Kind == AxisLog {
|
||||
return (math.Log10(v) - math.Log10(a.lo)) / (math.Log10(a.hi) - math.Log10(a.lo))
|
||||
@@ -146,7 +156,7 @@ func (a ChartAxisPart) fraction(v float64) float64 {
|
||||
return (v - a.lo) / (a.hi - a.lo)
|
||||
}
|
||||
|
||||
// Map returns the pixel of v along the axis inside plot.
|
||||
// Map returns the pixel position of v.
|
||||
func (a ChartAxisPart) Map(plot ui.Rect, v float64) float32 {
|
||||
f := float32(a.fraction(v))
|
||||
if a.horizontal() {
|
||||
@@ -155,7 +165,7 @@ func (a ChartAxisPart) Map(plot ui.Rect, v float64) float32 {
|
||||
return plot.Y + plot.H - f*plot.H
|
||||
}
|
||||
|
||||
// Invert returns the value at pixel px along the axis inside plot.
|
||||
// Invert returns the value at a pixel position.
|
||||
func (a ChartAxisPart) Invert(plot ui.Rect, px float32) float64 {
|
||||
f := float64((px - plot.X) / plot.W)
|
||||
if !a.horizontal() {
|
||||
@@ -191,7 +201,7 @@ func (a ChartAxisPart) Label(v float64) string {
|
||||
return strconv.FormatFloat(v, 'g', 6, 64)
|
||||
}
|
||||
|
||||
// readout writes v one decimal finer than ticks, for crosshair labels.
|
||||
// readout uses finer precision than ticks.
|
||||
func (a ChartAxisPart) readout(v float64) string {
|
||||
switch {
|
||||
case a.opts.Format != nil, a.opts.Kind == AxisCategory, a.opts.Kind == AxisTime:
|
||||
@@ -204,11 +214,16 @@ func (a ChartAxisPart) readout(v float64) string {
|
||||
return strconv.FormatFloat(v, 'g', 6, 64)
|
||||
}
|
||||
|
||||
// Draw paints the axis line and tick labels on its side of plot.
|
||||
// Draw paints the axis line and tick labels.
|
||||
func (a ChartAxisPart) Draw(p *ui.Painter, plot ui.Rect) {
|
||||
pos := make([]float32, len(a.ticks))
|
||||
labels := make([]string, len(a.ticks))
|
||||
for i, t := range a.ticks {
|
||||
spacing := a.size * 2
|
||||
if a.horizontal() {
|
||||
spacing = a.size * 4
|
||||
}
|
||||
ticks := a.drawTicks(plot, spacing)
|
||||
pos := make([]float32, len(ticks))
|
||||
labels := make([]string, len(ticks))
|
||||
for i, t := range ticks {
|
||||
pos[i], labels[i] = a.Map(plot, t), a.Label(t)
|
||||
}
|
||||
k := a.k
|
||||
@@ -227,3 +242,23 @@ func (a ChartAxisPart) Draw(p *ui.Painter, plot ui.Rect) {
|
||||
internal.LabelsY(p, plot.X+plot.W+6, pos, labels, true, a.size, k)
|
||||
}
|
||||
}
|
||||
|
||||
func (a ChartAxisPart) drawTicks(plot ui.Rect, spacing float32) []float64 {
|
||||
length := plot.H
|
||||
if a.horizontal() {
|
||||
length = plot.W
|
||||
}
|
||||
limit := max(2, int(length/max(spacing, 1)))
|
||||
if len(a.ticks) <= limit {
|
||||
return a.ticks
|
||||
}
|
||||
step := (len(a.ticks) + limit - 1) / limit
|
||||
out := make([]float64, 0, limit+1)
|
||||
for i := 0; i < len(a.ticks); i += step {
|
||||
out = append(out, a.ticks[i])
|
||||
}
|
||||
if out[len(out)-1] != a.ticks[len(a.ticks)-1] {
|
||||
out = append(out, a.ticks[len(a.ticks)-1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -53,11 +53,15 @@ func TestChartAxisTimeAndCategory(t *testing.T) {
|
||||
|
||||
func TestChartAxisBad(t *testing.T) {
|
||||
for name, o := range map[string]ChartAxisOptions{
|
||||
"empty": {},
|
||||
"log zero": {Kind: AxisLog, Min: 0, Max: 10},
|
||||
"time": {Kind: AxisTime},
|
||||
"category": {Kind: AxisCategory},
|
||||
"side": {Min: 0, Max: 1, Side: 9},
|
||||
"empty": {},
|
||||
"log zero": {Kind: AxisLog, Min: 0, Max: 10},
|
||||
"time": {Kind: AxisTime},
|
||||
"category": {Kind: AxisCategory},
|
||||
"side": {Min: 0, Max: 1, Side: 9},
|
||||
"negative side": {Min: 0, Max: 1, Side: -1},
|
||||
"infinite domain": {Min: 0, Max: math.Inf(1)},
|
||||
"outside tick": {Min: 0, Max: 1, Ticks: []float64{2}},
|
||||
"infinite tick": {Min: 0, Max: 1, Ticks: []float64{math.Inf(1)}},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
@@ -70,7 +74,7 @@ func TestChartAxisBad(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// partsChart composes the public parts into a custom chart over three points.
|
||||
// partsChart composes public parts over three points.
|
||||
func partsChart(c *ui.Context, notes []ChartNote, crosshair bool) ChartResult {
|
||||
pts := [][2]float64{{2, 30}, {5, 80}, {8, 55}}
|
||||
x := ChartAxis(c, ChartAxisOptions{Min: 0, Max: 10})
|
||||
@@ -127,3 +131,27 @@ func TestChartAxisSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestChartAxisLargeCategoriesBounded(t *testing.T) {
|
||||
categories := make([]string, 100_000)
|
||||
for i := range categories {
|
||||
categories[i] = fmt.Sprint(i)
|
||||
}
|
||||
a := axisIn(ChartAxisOptions{Kind: AxisCategory, Categories: categories})
|
||||
if n := len(a.Ticks()); n > 65 || n == 0 {
|
||||
t.Fatalf("built %d category ticks", n)
|
||||
}
|
||||
if a.Label(99999) != "99999" || a.Map(ui.Rect{W: 1000}, 99999) < 999 {
|
||||
t.Fatal("last category mapping")
|
||||
}
|
||||
}
|
||||
|
||||
func charts2MustPanic(t *testing.T, view func(*ui.Context)) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("invalid options accepted")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); view(c) }, 320, 240)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ type ChartBrushOptions struct {
|
||||
Label string
|
||||
// Min and Max span the track; Min < Max.
|
||||
Min, Max float64
|
||||
// Step is the grid both ends snap to; 0 means a hundredth of the span.
|
||||
// Step defaults to one hundredth of the span.
|
||||
Step float64
|
||||
// Values draws a preview line across the track.
|
||||
Values []float64
|
||||
@@ -33,10 +33,10 @@ type brushDrag struct {
|
||||
start float64
|
||||
}
|
||||
|
||||
// ChartBrush selects the range *value on a track: drag the window or its ends, or use the keys.
|
||||
// ChartBrush selects a range with dragging and keyboard controls.
|
||||
func ChartBrush(c *ui.Context, value *[2]float64, opts ChartBrushOptions) ChoiceResult {
|
||||
if opts.Label == "" || !(opts.Max > opts.Min) || opts.Step < 0 || opts.Height < 0 {
|
||||
panic("mujica: ChartBrush needs a Label, Min < Max, Step ≥ 0 and Height ≥ 0")
|
||||
if opts.Label == "" || !(opts.Max > opts.Min) || math.IsInf(opts.Min, 0) || math.IsInf(opts.Max, 0) || math.IsNaN(opts.Step) || math.IsInf(opts.Step, 0) || opts.Step < 0 || math.IsNaN(float64(opts.Height)) || math.IsInf(float64(opts.Height), 0) || opts.Height != 0 && opts.Height < theme.MinHitSize {
|
||||
panic("mujica: ChartBrush needs a Label, finite domain and step, and Height ≥ 28")
|
||||
}
|
||||
step := opts.Step
|
||||
if step == 0 {
|
||||
@@ -63,7 +63,7 @@ func ChartBrush(c *ui.Context, value *[2]float64, opts ChartBrushOptions) Choice
|
||||
track := ui.Box(c).Height(h).FillWidth().MinWidth(0).Radius(theme.ControlRadius).Background(k.SurfaceHover)
|
||||
b := track.Bounds()
|
||||
internal.Remeasure(c, track, b.W, b.H)
|
||||
at := func(v float64) float32 { return float32((v - opts.Min) / (opts.Max - opts.Min)) * b.W }
|
||||
at := func(v float64) float32 { return float32((v-opts.Min)/(opts.Max-opts.Min)) * b.W }
|
||||
from := func(px float32) float64 { return opts.Min + float64(px/max(b.W, 1))*(opts.Max-opts.Min) }
|
||||
drag := ui.Local(track, "mujica.brush.drag", func() brushDrag { return brushDrag{part: -1} })
|
||||
set := func(lo, hi float64) {
|
||||
@@ -72,11 +72,28 @@ func ChartBrush(c *ui.Context, value *[2]float64, opts ChartBrushOptions) Choice
|
||||
*value = [2]float64{lo, hi}
|
||||
}
|
||||
}
|
||||
track.Draw(func(p *ui.Painter, r ui.Rect) { brushPaint(p, r, *value, opts, k) })
|
||||
move := func(lo, hi float64) {
|
||||
w := hi - lo
|
||||
lo = max(spec.lo, min(lo, spec.hi-w))
|
||||
set(lo, lo+w)
|
||||
}
|
||||
preview := ui.Local(track, "mujica.brush.preview", func() brushPreview { return brushPreview{} })
|
||||
if id := chartID(opts.Values); preview.id != id {
|
||||
*preview = brushPreview{id: id, lo: math.Inf(1), hi: math.Inf(-1)}
|
||||
for _, v := range opts.Values {
|
||||
if math.IsInf(v, 0) {
|
||||
panic("mujica: ChartBrush preview values must be finite or NaN")
|
||||
}
|
||||
if !math.IsNaN(v) {
|
||||
preview.lo, preview.hi = min(preview.lo, v), max(preview.hi, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
track.Draw(func(p *ui.Painter, r ui.Rect) { brushPaint(p, r, *value, opts, k, preview) })
|
||||
track.Children(func() {
|
||||
x0, x1 := at(value[0]), at(value[1])
|
||||
end := func(i int, x float32) *ui.Element {
|
||||
e := ui.Box(c).Absolute().Left(x - theme.MinHitSize/2).Top(0).Size(theme.MinHitSize, h).Focusable().
|
||||
e := ui.Box(c).Absolute().Left(x-theme.MinHitSize/2).Top(0).Size(theme.MinHitSize, h).Focusable().
|
||||
Role(ui.RoleSlider).Label(fmt.Sprintf(msg(c, []string{"chart_brush.start", "chart_brush.end"}[i]), val(value[i])))
|
||||
internal.Ring(e, k.Focus, theme.ControlRadius)
|
||||
return e.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
@@ -86,11 +103,12 @@ func ChartBrush(c *ui.Context, value *[2]float64, opts ChartBrushOptions) Choice
|
||||
}
|
||||
// Tab order runs start, window, end.
|
||||
ends := [2]*ui.Element{end(0, x0)}
|
||||
window := ui.Box(c).Absolute().Left(x0 + theme.MinHitSize/2).Top(0).Width(max(x1-x0-theme.MinHitSize, 1)).Height(h).Focusable().
|
||||
window := ui.Box(c).Absolute().Left(x0 + theme.MinHitSize/2).Top(0).Width(max(x1-x0-theme.MinHitSize, theme.MinHitSize)).Height(h).Focusable().
|
||||
Role(ui.RoleGroup).Label(fmt.Sprintf(msg(c, "chart_brush.window"), opts.Label, val(value[0]), val(value[1])))
|
||||
internal.Ring(window, k.Focus, theme.ControlRadius)
|
||||
ends[1] = end(1, x1)
|
||||
if res.Element.IsDisabled() {
|
||||
drag.part = -1
|
||||
return
|
||||
}
|
||||
for i, end := range ends {
|
||||
@@ -116,19 +134,23 @@ func ChartBrush(c *ui.Context, value *[2]float64, opts ChartBrushOptions) Choice
|
||||
case window.Shortcut(0, ui.KeyEnd):
|
||||
set(spec.hi-w, spec.hi)
|
||||
}
|
||||
brushPointer(track, window, ends, drag, value, from, set)
|
||||
brushPointer(track, window, ends, drag, value, from, set, move)
|
||||
})
|
||||
ui.Text(c, val(value[0])+" – "+val(value[1])).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleStatus)
|
||||
})
|
||||
res.changed = *value != before
|
||||
if res.changed {
|
||||
c.Announce(fmt.Sprintf(msg(c, "chart_brush.window"), opts.Label, val(value[0]), val(value[1])))
|
||||
slog.Debug("mujica: chart_brush range", "from", value[0], "to", value[1])
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// brushKey steps an end for the arrow, Page, Home and End keys.
|
||||
// brushKey handles arrow, Page, Home and End keys.
|
||||
func brushKey(e *ui.Element, v float64, spec numberInputSpec) (float64, bool) {
|
||||
if e.IsDisabled() {
|
||||
return v, false
|
||||
}
|
||||
switch {
|
||||
case e.Shortcut(0, ui.KeyLeft), e.Shortcut(0, ui.KeyDown):
|
||||
return spec.stepBy(v, -1), true
|
||||
@@ -146,8 +168,8 @@ func brushKey(e *ui.Element, v float64, spec numberInputSpec) (float64, bool) {
|
||||
return v, false
|
||||
}
|
||||
|
||||
// brushPointer drags an end, the window, or a new range across the track.
|
||||
func brushPointer(track, window *ui.Element, ends [2]*ui.Element, drag *brushDrag, value *[2]float64, from func(float32) float64, set func(lo, hi float64)) {
|
||||
// brushPointer drags handles, the window, or a new range.
|
||||
func brushPointer(track, window *ui.Element, ends [2]*ui.Element, drag *brushDrag, value *[2]float64, from func(float32) float64, set, move func(lo, hi float64)) {
|
||||
px, _, _ := track.PointerPosition()
|
||||
at := from(px)
|
||||
parts := []*ui.Element{ends[0], ends[1], window, track}
|
||||
@@ -171,34 +193,43 @@ func brushPointer(track, window *ui.Element, ends [2]*ui.Element, drag *brushDra
|
||||
case 1:
|
||||
set(value[0], max(at, value[0]))
|
||||
case 2:
|
||||
set(at-drag.grab, at-drag.grab+w)
|
||||
lo := at - drag.grab
|
||||
// Shift both ends together at a boundary.
|
||||
move(lo, lo+w)
|
||||
default:
|
||||
set(min(drag.start, at), max(drag.start, at))
|
||||
}
|
||||
}
|
||||
|
||||
// brushPaint draws the preview line, dims outside the range and frames the window.
|
||||
func brushPaint(p *ui.Painter, r ui.Rect, value [2]float64, opts ChartBrushOptions, k theme.Tokens) {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for _, v := range opts.Values {
|
||||
if !math.IsNaN(v) {
|
||||
lo, hi = min(lo, v), max(hi, v)
|
||||
}
|
||||
}
|
||||
// brushPaint draws the preview and frames the selected range.
|
||||
type brushPreview struct {
|
||||
id string
|
||||
lo, hi float64
|
||||
width float32
|
||||
runs [][]int
|
||||
}
|
||||
|
||||
func brushPaint(p *ui.Painter, r ui.Rect, value [2]float64, opts ChartBrushOptions, k theme.Tokens, preview *brushPreview) {
|
||||
lo, hi := preview.lo, preview.hi
|
||||
if n := len(opts.Values); n > 1 && hi > lo {
|
||||
x := func(i int) float32 { return r.X + r.W*float32(i)/float32(n-1) }
|
||||
chartRuns(opts.Values, x, func(idx []int) {
|
||||
x := func(i int) float32 { return r.W * float32(i) / float32(n-1) }
|
||||
if preview.width != r.W || preview.runs == nil {
|
||||
preview.width, preview.runs = r.W, nil
|
||||
chartRuns(opts.Values, x, func(idx []int) { preview.runs = append(preview.runs, idx) })
|
||||
}
|
||||
for _, idx := range preview.runs {
|
||||
var line ui.Path
|
||||
for j, i := range idx {
|
||||
y := r.Y + 4 + (r.H-8)*float32(1-(opts.Values[i]-lo)/(hi-lo))
|
||||
px := internal.Snap(p, r.X+x(i))
|
||||
py := internal.Snap(p, r.Y+4+(r.H-8)*float32(1-(opts.Values[i]-lo)/(hi-lo)))
|
||||
if j == 0 {
|
||||
line.MoveTo(x(i), y)
|
||||
line.MoveTo(px, py)
|
||||
} else {
|
||||
line.LineTo(x(i), y)
|
||||
line.LineTo(px, py)
|
||||
}
|
||||
}
|
||||
p.StrokePath(&line, 1.25, k.TextMuted)
|
||||
})
|
||||
}
|
||||
}
|
||||
x0 := r.X + r.W*float32((value[0]-opts.Min)/(opts.Max-opts.Min))
|
||||
x1 := r.X + r.W*float32((value[1]-opts.Min)/(opts.Max-opts.Min))
|
||||
|
||||
@@ -2,6 +2,7 @@ package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
@@ -121,3 +122,45 @@ func TestChartBrushSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestChartBrushWindowDragPreservesSpanAtEdges(t *testing.T) {
|
||||
h := &brushHarness{value: [2]float64{20, 60}}
|
||||
tt := ui.NewTester(h.view, 460, 200)
|
||||
b := h.el.Bounds()
|
||||
tt.Press(b.X+160, b.Y+24)
|
||||
tt.Move(b.X+380, b.Y+24)
|
||||
tt.Release(b.X+380, b.Y+24)
|
||||
if h.value != [2]float64{60, 100} {
|
||||
t.Fatalf("right edge shrank window: %v", h.value)
|
||||
}
|
||||
tt.Press(b.X+320, b.Y+24)
|
||||
tt.Move(b.X-50, b.Y+24)
|
||||
tt.Release(b.X-50, b.Y+24)
|
||||
if h.value != [2]float64{0, 40} {
|
||||
t.Fatalf("left edge shrank window: %v", h.value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartBrushInvalidMetrics(t *testing.T) {
|
||||
for _, o := range []ChartBrushOptions{
|
||||
{Label: "Range", Max: 100, Step: math.Inf(1)},
|
||||
{Label: "Range", Max: math.Inf(1)},
|
||||
{Label: "Range", Max: 100, Height: 10},
|
||||
} {
|
||||
charts2MustPanic(t, func(c *ui.Context) { v := [2]float64{20, 40}; ChartBrush(c, &v, o) })
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartBrushNarrowRangeHitAndAnnouncement(t *testing.T) {
|
||||
h := &brushHarness{value: [2]float64{40, 41}}
|
||||
tt := ui.NewTester(h.view, 460, 200)
|
||||
b, ok := tt.Find("Months: 40 to 41")
|
||||
if !ok || b.W < 28 || b.H < 28 {
|
||||
t.Fatalf("window hit area %v", b)
|
||||
}
|
||||
tabTo(t, tt, "Months: 40 to 41")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if said := tt.Announcements(); len(said) == 0 || said[len(said)-1] != "Months: 41 to 42" {
|
||||
t.Fatalf("window announcement %q", said)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
"math"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
@@ -9,13 +10,13 @@ import (
|
||||
|
||||
// ChartCrosshairOptions configure a ChartCrosshair.
|
||||
type ChartCrosshairOptions struct {
|
||||
// X draws the vertical line and its label on X's side; Y the horizontal one.
|
||||
// X draws vertical lines; Y draws horizontal lines.
|
||||
X, Y *ChartAxisPart
|
||||
// Reach is how far, in DIPs, Snap looks for a point; 0 means 24.
|
||||
// Reach defaults to 24 DIPs for point snapping.
|
||||
Reach float32
|
||||
}
|
||||
|
||||
// ChartCrosshairPart is a crosshair ready to snap to points and draw.
|
||||
// ChartCrosshairPart draws and snaps the crosshair.
|
||||
type ChartCrosshairPart struct {
|
||||
opts ChartCrosshairOptions
|
||||
k theme.Tokens
|
||||
@@ -23,9 +24,9 @@ type ChartCrosshairPart struct {
|
||||
pad float32
|
||||
}
|
||||
|
||||
// ChartCrosshair crosses lines through a point with value labels on the axes.
|
||||
// ChartCrosshair draws crossed lines and axis readouts.
|
||||
func ChartCrosshair(c *ui.Context, opts ChartCrosshairOptions) ChartCrosshairPart {
|
||||
if opts.X == nil && opts.Y == nil || opts.Reach < 0 {
|
||||
if opts.X == nil && opts.Y == nil || opts.Reach < 0 || math.IsNaN(float64(opts.Reach)) || math.IsInf(float64(opts.Reach), 0) {
|
||||
panic("mujica: ChartCrosshair needs an axis and Reach ≥ 0")
|
||||
}
|
||||
if opts.Reach == 0 {
|
||||
@@ -34,12 +35,12 @@ func ChartCrosshair(c *ui.Context, opts ChartCrosshairOptions) ChartCrosshairPar
|
||||
return ChartCrosshairPart{opts: opts, k: Tokens(c), size: FontSize(c, theme.CaptionSize), pad: Density(c).Unit()}
|
||||
}
|
||||
|
||||
// Snap returns the point nearest (x, y) within Reach, or -1.
|
||||
// Snap returns the nearest point within Reach, or -1.
|
||||
func (h ChartCrosshairPart) Snap(n int, at func(i int) (x, y float32), x, y float32) int {
|
||||
return internal.Nearest(n, at, x, y, h.opts.Reach, false)
|
||||
}
|
||||
|
||||
// Draw paints the lines through (x, y) inside plot and each axis's value label.
|
||||
// Draw paints crossed lines and value labels.
|
||||
func (h ChartCrosshairPart) Draw(p *ui.Painter, plot ui.Rect, x, y float32) {
|
||||
k := h.k
|
||||
if a := h.opts.X; a != nil {
|
||||
@@ -62,7 +63,7 @@ func (h ChartCrosshairPart) Draw(p *ui.Painter, plot ui.Rect, x, y float32) {
|
||||
}
|
||||
}
|
||||
|
||||
// pill writes s on a Text-colored tag at the axis, outside the plot.
|
||||
// pill paints a value tag outside the plot.
|
||||
func (h ChartCrosshairPart) pill(p *ui.Painter, s string, x, y float32, across, far bool) {
|
||||
w, ht := p.MeasureText(0, ui.Span{Text: s, Size: h.size})
|
||||
box := ui.Rect{W: w + 2*h.pad, H: ht + h.pad}
|
||||
|
||||
@@ -2,6 +2,7 @@ package mujica
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
@@ -14,7 +15,7 @@ func TestChartCrosshairSnapsAndFollowsKeys(t *testing.T) {
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
b := h.res.Element.Bounds()
|
||||
plot := chartInset(ui.Rect{X: b.X, Y: b.Y, W: b.W, H: 240}, 52, 26)
|
||||
// Point 1 is (5, 80); the pointer rests 10 DIPs off it.
|
||||
// Point 1 is ten DIPs from the pointer.
|
||||
px, py := plot.X+plot.W/2, plot.Y+plot.H*0.2
|
||||
tt.Move(px+10, py-6)
|
||||
if h.res.Point != 1 || chartReadout(tt) != "x 5 · y 80" {
|
||||
@@ -34,7 +35,7 @@ func TestChartCrosshairSnapsAndFollowsKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// crosshairAt reports a TextMuted pixel at (x, y), where the crosshair runs.
|
||||
// crosshairAt checks a crosshair pixel.
|
||||
func crosshairAt(tt *ui.Tester, x, y int) bool {
|
||||
want := toRGBA(theme.Light().TextMuted)
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
@@ -67,3 +68,22 @@ func TestChartCrosshairSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestChartCrosshairDisablesFocusedReadout(t *testing.T) {
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult { return partsChart(c, nil, true) }}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Parts")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
h.disabled = true
|
||||
tt.Frame()
|
||||
if h.res.Point != -1 {
|
||||
t.Fatalf("disabled readout %d", h.res.Point)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartCrosshairFiniteReach(t *testing.T) {
|
||||
charts2MustPanic(t, func(c *ui.Context) {
|
||||
a := ChartAxis(c, ChartAxisOptions{Max: 1})
|
||||
ChartCrosshair(c, ChartCrosshairOptions{X: &a, Reach: float32(math.NaN())})
|
||||
})
|
||||
}
|
||||
@@ -14,7 +14,7 @@ type ChartEmptyStateOptions struct {
|
||||
Empty string
|
||||
}
|
||||
|
||||
// ChartEmptyState holds a chart's place while it loads, fails or has no data; Changed reports Retry.
|
||||
// ChartEmptyState preserves plot size and reports retry clicks.
|
||||
func ChartEmptyState(c *ui.Context, status DataStatus, opts ChartEmptyStateOptions) ChoiceResult {
|
||||
if opts.Height < 0 {
|
||||
panic("mujica: ChartEmptyState Height must be ≥ 0")
|
||||
@@ -26,7 +26,7 @@ func ChartEmptyState(c *ui.Context, status DataStatus, opts ChartEmptyStateOptio
|
||||
var res ChoiceResult
|
||||
res.Element = ui.Column(c).Height(h).FillWidth().MinWidth(0)
|
||||
res.Element.Children(func() {
|
||||
res.changed = dataPlaceholder(c, status, opts.Error, opts.Empty, msg(c, "chart.empty")) && !res.Element.IsDisabled()
|
||||
res.changed = dataPlaceholder(c, status, opts.Error, opts.Empty, msg(c, "chart_empty_state.empty")) && !res.Element.IsDisabled()
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
@@ -16,9 +17,9 @@ type ChartExportOptions struct {
|
||||
Scale float32
|
||||
}
|
||||
|
||||
// ChartExport renders chart off screen in this window's theme and returns PNG bytes; it writes no file.
|
||||
// ChartExport returns a PNG rendered in the current theme.
|
||||
func ChartExport(c *ui.Context, chart func(c *ui.Context), opts ChartExportOptions) ([]byte, error) {
|
||||
if chart == nil || opts.Width <= 0 || opts.Height <= 0 || opts.Scale < 0 {
|
||||
if chart == nil || opts.Width <= 0 || opts.Height <= 0 || opts.Scale < 0 || math.IsNaN(float64(opts.Scale)) || math.IsInf(float64(opts.Scale), 0) {
|
||||
panic("mujica: ChartExport needs a chart, a positive size and Scale ≥ 0")
|
||||
}
|
||||
scale := opts.Scale
|
||||
@@ -31,7 +32,11 @@ func ChartExport(c *ui.Context, chart func(c *ui.Context), opts ChartExportOptio
|
||||
settings.Mode = Dark
|
||||
}
|
||||
tokens := w.tokens
|
||||
img := ui.Render(func(c *ui.Context) {
|
||||
ready := false
|
||||
tester := ui.NewTester(func(c *ui.Context) {
|
||||
if !ready {
|
||||
return
|
||||
}
|
||||
if settings.Mode == Dark {
|
||||
settings.Dark = &tokens
|
||||
} else {
|
||||
@@ -39,7 +44,12 @@ func ChartExport(c *ui.Context, chart func(c *ui.Context), opts ChartExportOptio
|
||||
}
|
||||
Use(c, settings)
|
||||
ui.Column(c).Fill().Background(tokens.Surface).Padding(Density(c).Unit() * 4).Children(func() { chart(c) })
|
||||
}, opts.Width, opts.Height, scale)
|
||||
}, opts.Width, opts.Height)
|
||||
tester.SetScale(scale)
|
||||
tester.SetPreferences(c.Preferences())
|
||||
ready = true
|
||||
tester.Frame()
|
||||
img := tester.Image()
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -71,3 +71,24 @@ func TestChartExportSnapshot(t *testing.T) {
|
||||
ui.Column(c).Padding(20).Children(func() { ui.Image(c, bmp).Size(300, 260) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestChartExportPreservesPreferences(t *testing.T) {
|
||||
var data []byte
|
||||
var got ui.Preferences
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Dark, Locale: ZhCN})
|
||||
if data != nil {
|
||||
return
|
||||
}
|
||||
var err error
|
||||
data, err = ChartExport(c, func(c *ui.Context) { got = c.Preferences(); ui.Text(c, "Scale") }, ChartExportOptions{Width: 100, Height: 80, Scale: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, 100, 80)
|
||||
data = nil
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 2, ReduceMotion: true, HighContrast: true})
|
||||
if got.TextScale != 2 || !got.ReduceMotion || !got.HighContrast {
|
||||
t.Fatalf("export preferences %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ func (ch *chart) build(spec ChartSpec) (ChartResult, *ui.Element) {
|
||||
if over && !plot.IsDisabled() {
|
||||
active = spec.Pick(local, x, y)
|
||||
}
|
||||
if active < 0 && plot.Focused() && st.focus >= 0 {
|
||||
if active < 0 && plot.Focused() && st.focus >= 0 && !plot.IsDisabled() {
|
||||
active = st.focus
|
||||
x, y = local.W/2, 0
|
||||
if spec.Anchor != nil {
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
|
||||
// ChartGridOptions configure a ChartGrid.
|
||||
type ChartGridOptions struct {
|
||||
// X and Y draw lines at their ticks; either may be nil.
|
||||
// X and Y provide grid ticks; either may be nil.
|
||||
X, Y *ChartAxisPart
|
||||
// Minor splits each major interval into this many parts with fainter lines.
|
||||
// Minor subdivides major intervals with fainter lines.
|
||||
Minor int
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ type ChartGridPart struct {
|
||||
major, minor ui.Color
|
||||
}
|
||||
|
||||
// ChartGrid draws pixel-aligned lines at the axes' ticks, and minor lines between them.
|
||||
// ChartGrid draws pixel-aligned major and minor lines.
|
||||
func ChartGrid(c *ui.Context, opts ChartGridOptions) ChartGridPart {
|
||||
if opts.Minor < 0 || opts.X == nil && opts.Y == nil {
|
||||
panic("mujica: ChartGrid needs an axis and Minor ≥ 0")
|
||||
@@ -50,7 +50,7 @@ func (g ChartGridPart) Draw(p *ui.Painter, plot ui.Rect) {
|
||||
internal.Rule(p, plot.X, plot.X+plot.W, at, col)
|
||||
}
|
||||
}
|
||||
ticks := a.Ticks()
|
||||
ticks := a.drawTicks(plot, 4)
|
||||
for i := 1; i < len(ticks) && g.opts.Minor > 0; i++ {
|
||||
for j := 1; j < g.opts.Minor; j++ {
|
||||
f := float64(j) / float64(g.opts.Minor)
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestChartGridPixelAlignedWithMinor(t *testing.T) {
|
||||
c := color.RGBAModel.Convert(img.At(100, py)).(color.RGBA)
|
||||
count[c]++
|
||||
}
|
||||
// Three major and two minor lines, each exactly one device pixel per DIP, no blur.
|
||||
// Three major and two minor lines stay pixel-aligned.
|
||||
if count[major] != 3*int(scale) || count[minor] != 2*int(scale) {
|
||||
t.Fatalf("scale %v: %d major and %d minor pixel rows", scale, count[major], count[minor])
|
||||
}
|
||||
|
||||
@@ -15,27 +15,31 @@ import (
|
||||
type ChartLegendItem struct {
|
||||
Name string
|
||||
Color ui.Color
|
||||
// Shape picks the marker: 0 circle, 1 square, 2 diamond, 3 triangle, 4 inverted.
|
||||
// Shape selects circle, square, diamond, triangle, or inverted triangle.
|
||||
Shape int
|
||||
}
|
||||
|
||||
// ChartLegendOptions configure a ChartLegend.
|
||||
type ChartLegendOptions struct {
|
||||
Vertical bool
|
||||
// MaxItems folds the rest behind a "+N more" button; 0 shows all.
|
||||
// MaxItems folds overflow; zero shows all items.
|
||||
MaxItems int
|
||||
}
|
||||
|
||||
// ChartLegendResult is a built legend and the series switched this frame.
|
||||
// ChartLegendResult exposes the element and legend events.
|
||||
type ChartLegendResult struct {
|
||||
Element *ui.Element
|
||||
toggled string
|
||||
Element *ui.Element
|
||||
toggled string
|
||||
expanded, folded bool
|
||||
}
|
||||
|
||||
// Toggled returns the series switched in this frame.
|
||||
func (r ChartLegendResult) Toggled() (string, bool) { return r.toggled, r.toggled != "" }
|
||||
|
||||
// ChartLegend lists series keys; with hidden set, each is a check box hiding its series.
|
||||
// Expanded reports the new state when expansion changes.
|
||||
func (r ChartLegendResult) Expanded() (bool, bool) { return r.expanded, r.folded }
|
||||
|
||||
// ChartLegend lists series; hidden enables visibility toggles.
|
||||
func ChartLegend(c *ui.Context, hidden *map[string]bool, items []ChartLegendItem, opts ChartLegendOptions) ChartLegendResult {
|
||||
if opts.MaxItems < 0 {
|
||||
panic("mujica: ChartLegend MaxItems must be ≥ 0")
|
||||
@@ -82,6 +86,7 @@ func ChartLegend(c *ui.Context, hidden *map[string]bool, items []ChartLegendItem
|
||||
b.Children(func() { ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).SingleLine() })
|
||||
if b.Clicked() && !b.IsDisabled() {
|
||||
*open = !*open
|
||||
res.expanded, res.folded = *open, true
|
||||
slog.Debug("mujica: chart_legend fold", "open", *open)
|
||||
}
|
||||
}
|
||||
@@ -89,7 +94,7 @@ func ChartLegend(c *ui.Context, hidden *map[string]bool, items []ChartLegendItem
|
||||
return res
|
||||
}
|
||||
|
||||
// chartLegendEntry builds one key; a check box when hidden is set. It reports a switch.
|
||||
// chartLegendEntry builds a series visibility control.
|
||||
func chartLegendEntry(c *ui.Context, hidden *map[string]bool, it ChartLegendItem, disabled bool) (string, bool) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
@@ -114,7 +119,7 @@ func chartLegendEntry(c *ui.Context, hidden *map[string]bool, it ChartLegendItem
|
||||
} else {
|
||||
e = ui.Row(c)
|
||||
}
|
||||
e.MinHeight(theme.MinHitSize).Gap(u*2).AlignItems(ui.Center).Padding(0, u).Radius(theme.ControlRadius)
|
||||
e.MinWidth(theme.MinHitSize).MinHeight(Density(c).ControlHeight()).Gap(u*2).AlignItems(ui.Center).Padding(0, u).Radius(theme.ControlRadius)
|
||||
col := it.Color
|
||||
if !on {
|
||||
col = k.TextMuted
|
||||
|
||||
@@ -93,3 +93,33 @@ func TestChartLegendSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestChartLegendDisabledAndExpansionEvents(t *testing.T) {
|
||||
hidden := map[string]bool{}
|
||||
disabled := false
|
||||
events := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
r := ChartLegend(c, &hidden, legendItems(), ChartLegendOptions{MaxItems: 2})
|
||||
if _, ok := r.Expanded(); ok {
|
||||
events++
|
||||
}
|
||||
})
|
||||
}, 500, 200)
|
||||
if err := tt.Click("+3 more"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Frame()
|
||||
if events != 1 || !tt.HasText("Sext") {
|
||||
t.Fatalf("expansion events %d", events)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Matins")
|
||||
_ = tt.Click("Show fewer")
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if hidden["Matins"] || events != 1 || !tt.HasText("Sext") {
|
||||
t.Fatalf("disabled legend %v events %d", hidden, events)
|
||||
}
|
||||
}
|
||||
@@ -204,3 +204,15 @@ func chartRuns(vals []float64, x func(i int) float32, f func(idx []int)) {
|
||||
i = j
|
||||
}
|
||||
}
|
||||
|
||||
func chartVisibleIndices(n, limit, active int) []int {
|
||||
step := max(1, (n+limit-1)/limit)
|
||||
out := make([]int, 0, min(n, limit+1))
|
||||
for i := 0; i < n; i += step {
|
||||
out = append(out, i)
|
||||
}
|
||||
if active >= 0 && active < n && active%step != 0 {
|
||||
out = append(out, active)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ChartTooltipLine is one row of a tooltip; a zero Color shows no marker.
|
||||
// ChartTooltipLine pairs readout text with an optional marker.
|
||||
type ChartTooltipLine struct {
|
||||
Text string
|
||||
Color ui.Color
|
||||
Shape int
|
||||
}
|
||||
|
||||
// ChartTooltip floats painted lines beside window point (x, y), avoiding edges.
|
||||
// ChartTooltip shows readouts beside a window point, avoiding edges.
|
||||
func ChartTooltip(c *ui.Context, x, y float32, lines []ChartTooltipLine) *ui.Element {
|
||||
if len(lines) == 0 {
|
||||
panic("mujica: ChartTooltip needs a line")
|
||||
@@ -37,29 +37,42 @@ func ChartTooltip(c *ui.Context, x, y float32, lines []ChartTooltipLine) *ui.Ele
|
||||
}
|
||||
w += pad * 2
|
||||
ww, wh := c.Size()
|
||||
left, top := x+gap, y+gap
|
||||
if left+w > ww-4 {
|
||||
left = x - gap - w
|
||||
w, h = min(w, max(1, ww-8)), min(h, max(1, wh-8))
|
||||
left, top := overlayPlace(c, ui.Rect{X: x, Y: y}, w, h, gap)
|
||||
if x+gap+w <= ww-4 {
|
||||
left = max(4, x+gap)
|
||||
} else {
|
||||
left = max(4, min(x-gap-w, ww-4-w))
|
||||
}
|
||||
if top+h > wh-4 {
|
||||
top = y - gap - h
|
||||
}
|
||||
left, top = max(4, left), max(4, top)
|
||||
var tip *ui.Element
|
||||
ui.Overlay(c, func() {
|
||||
tip = ui.Box(c).Absolute().Left(left).Top(top).Size(w, h).PassThrough().Role(ui.RoleNone)
|
||||
overlayStyle(c, tip, 1)
|
||||
tip.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
at := r.Y + pad
|
||||
tip = ui.Column(c).Absolute().Left(left).Top(top).Size(w, h).Role(ui.RoleNone)
|
||||
overlayStyle(c, tip, overlayEnter(c, tip))
|
||||
tip.Children(func() {
|
||||
inner := max(1, w-2*pad)
|
||||
content := float32(0)
|
||||
for i, l := range lines {
|
||||
tx := r.X + pad
|
||||
width := inner
|
||||
if l.Color.A > 0 {
|
||||
internal.Marker(p, l.Shape, tx+5, at+lh[i]/2, 9, l.Color)
|
||||
tx += 14
|
||||
width = max(1, width-14)
|
||||
}
|
||||
p.RichText(tx, at, 0, ui.Span{Text: l.Text, Size: size, Weight: chartTipWeight(i), Color: k.Text})
|
||||
at += lh[i]
|
||||
_, lh[i] = c.MeasureText(width, ui.Span{Text: l.Text, Size: size, Weight: chartTipWeight(i)})
|
||||
content += lh[i]
|
||||
}
|
||||
ui.Scroll(c).Fill().Padding(pad).Children(func() {
|
||||
ui.Box(c).Width(inner).Height(content).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
at := r.Y
|
||||
for i, l := range lines {
|
||||
tx, width := r.X, inner
|
||||
if l.Color.A > 0 {
|
||||
internal.Marker(p, l.Shape, tx+5, at+lh[i]/2, 9, l.Color)
|
||||
tx, width = tx+14, max(1, width-14)
|
||||
}
|
||||
p.RichText(tx, at, width, ui.Span{Text: l.Text, Size: size, Weight: chartTipWeight(i), Color: k.Text})
|
||||
at += lh[i]
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
return tip
|
||||
@@ -73,7 +86,7 @@ func chartTipWeight(i int) int {
|
||||
return 400
|
||||
}
|
||||
|
||||
// chartTipLines splits a readout at " · " into a heading and rows.
|
||||
// chartTipLines splits a readout into tooltip lines.
|
||||
func chartTipLines(readout string) []ChartTooltipLine {
|
||||
var out []ChartTooltipLine
|
||||
for _, s := range strings.Split(readout, " · ") {
|
||||
|
||||
@@ -42,6 +42,7 @@ func TestChartTooltipFollowsFocusAndPointer(t *testing.T) {
|
||||
return LineChart(c, lineSeries(), LineChartOptions{Frame: ChartFrame{Label: "Chapel"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
tabTo(t, tt, "Chapel")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if !tooltipShown(tt) {
|
||||
@@ -58,7 +59,7 @@ func TestChartTooltipFollowsFocusAndPointer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// tooltipShown looks for a run of Surface pixels too long for a marker ring.
|
||||
// tooltipShown finds Surface runs longer than marker rings.
|
||||
func tooltipShown(tt *ui.Tester) bool {
|
||||
img := tt.Image()
|
||||
want := toRGBA(theme.Light().Surface)
|
||||
@@ -84,3 +85,19 @@ func TestChartTooltipSnapshot(t *testing.T) {
|
||||
ChartTooltip(c, 20, 20, []ChartTooltipLine{{Text: "Lauds"}, {Text: "Pews 5", Color: pal[0]}, {Text: "Candles 2", Color: pal[1], Shape: 1}})
|
||||
})
|
||||
}
|
||||
|
||||
func TestChartTooltipFitsLongContent(t *testing.T) {
|
||||
var b ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
lines := make([]ChartTooltipLine, 40)
|
||||
for i := range lines {
|
||||
lines[i].Text = "A very long series name that wraps across the tooltip panel"
|
||||
}
|
||||
b = ChartTooltip(c, 159, 99, lines).Bounds()
|
||||
}, 160, 100)
|
||||
tt.Frame()
|
||||
if b.X < 4 || b.Y < 4 || b.X+b.W > 156 || b.Y+b.H > 96 {
|
||||
t.Fatalf("tooltip outside window: %v", b)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ type ChordDiagramOptions struct {
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// chordLayout is each group's arc and each pair's chord ends, as angles.
|
||||
// chordLayout stores group arcs and relation endpoints.
|
||||
type chordLayout struct {
|
||||
arcs [][2]float64
|
||||
sub [][][2]float64
|
||||
@@ -28,6 +28,11 @@ const chordGap = 0.04
|
||||
|
||||
func chordPlace(matrix [][]float64) chordLayout {
|
||||
n := len(matrix)
|
||||
for _, row := range matrix {
|
||||
if len(row) != n {
|
||||
panic("mujica: ChordDiagram matrix must be square")
|
||||
}
|
||||
}
|
||||
l := chordLayout{arcs: make([][2]float64, n), sub: make([][][2]float64, n)}
|
||||
sums := make([]float64, n)
|
||||
for i, row := range matrix {
|
||||
@@ -39,16 +44,20 @@ func chordPlace(matrix [][]float64) chordLayout {
|
||||
panic("mujica: ChordDiagram values must be finite and ≥ 0")
|
||||
}
|
||||
sums[i] += v
|
||||
if j > i && v+matrix[j][i] > 0 {
|
||||
if j >= i && v+matrix[j][i] > 0 {
|
||||
l.pairs = append(l.pairs, [2]int{i, j})
|
||||
}
|
||||
}
|
||||
l.total += sums[i]
|
||||
}
|
||||
if math.IsInf(l.total, 0) {
|
||||
panic("mujica: ChordDiagram total must be finite")
|
||||
}
|
||||
if l.total == 0 {
|
||||
return l
|
||||
}
|
||||
span := 2*math.Pi - chordGap*float64(n)
|
||||
gap := min(chordGap, math.Pi/float64(n))
|
||||
span := 2*math.Pi - gap*float64(n)
|
||||
a := 0.0
|
||||
for i := range n {
|
||||
l.arcs[i] = [2]float64{a, a + span*sums[i]/l.total}
|
||||
@@ -59,16 +68,23 @@ func chordPlace(matrix [][]float64) chordLayout {
|
||||
l.sub[i][j] = [2]float64{at, at + w}
|
||||
at += w
|
||||
}
|
||||
a = l.arcs[i][1] + chordGap
|
||||
a = l.arcs[i][1] + gap
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// ChordDiagram joins group arcs by chords; reading a group highlights them.
|
||||
// ChordDiagram connects group arcs and highlights read relationships.
|
||||
func ChordDiagram(c *ui.Context, names []string, matrix [][]float64, opts ChordDiagramOptions) ChartResult {
|
||||
if len(names) != len(matrix) {
|
||||
panic("mujica: ChordDiagram needs a name per matrix row")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, name := range names {
|
||||
if name == "" || seen[name] {
|
||||
panic("mujica: ChordDiagram groups need unique names")
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
ch := newChart(c, opts.Frame)
|
||||
l := chartMemo(ch, "chords", chartID(matrix), func() chordLayout { return chordPlace(matrix) })
|
||||
k := Tokens(c)
|
||||
@@ -82,6 +98,7 @@ func ChordDiagram(c *ui.Context, names []string, matrix [][]float64, opts ChordD
|
||||
geom := func(r ui.Rect) (cx, cy, rad float32) {
|
||||
return r.X + r.W/2, r.Y + r.H/2, min(r.W, r.H)/2 - 30
|
||||
}
|
||||
visible := ui.Local(ch.res.Element, "mujica.chord.visible", func() []int { return nil })
|
||||
spec := ChartSpec{Marks: n}
|
||||
spec.Readout = func(i int) string {
|
||||
if i < len(names) {
|
||||
@@ -122,21 +139,26 @@ func ChordDiagram(c *ui.Context, names []string, matrix [][]float64, opts ChordD
|
||||
}
|
||||
return l.pairs[active-len(names)] == pair
|
||||
}
|
||||
for pi, pair := range l.pairs {
|
||||
*visible = chartVisibleIndices(len(l.pairs), max(1, int(rad*4)), active-len(names))
|
||||
for _, pi := range *visible {
|
||||
pair := l.pairs[pi]
|
||||
i, j := pair[0], pair[1]
|
||||
s, t := l.sub[i][j], l.sub[j][i]
|
||||
var path ui.Path
|
||||
internal.Arc(&path, cx, cy, rad, s[0], s[1], true)
|
||||
internal.Arc(&path, cx, cy, rad, s[0], s[1], true, p)
|
||||
tx, ty := internal.Polar(cx, cy, rad, t[0])
|
||||
path.QuadTo(cx, cy, tx, ty)
|
||||
internal.Arc(&path, cx, cy, rad, t[0], t[1], false)
|
||||
path.QuadTo(internal.Snap(p, cx), internal.Snap(p, cy), internal.Snap(p, tx), internal.Snap(p, ty))
|
||||
internal.Arc(&path, cx, cy, rad, t[0], t[1], false, p)
|
||||
sx, sy := internal.Polar(cx, cy, rad, s[0])
|
||||
path.QuadTo(cx, cy, sx, sy).Close()
|
||||
path.QuadTo(internal.Snap(p, cx), internal.Snap(p, cy), internal.Snap(p, sx), internal.Snap(p, sy)).Close()
|
||||
alpha := float32(0.45)
|
||||
if !lit(pair) {
|
||||
alpha = 0.08
|
||||
}
|
||||
p.FillPath(&path, pal[i%len(pal)].Alpha(alpha))
|
||||
if lit(pair) {
|
||||
p.StrokePath(&path, 1, pal[i%len(pal)])
|
||||
}
|
||||
if active == len(names)+pi {
|
||||
p.StrokePath(&path, 1.5, k.Text)
|
||||
}
|
||||
@@ -146,7 +168,7 @@ func ChordDiagram(c *ui.Context, names []string, matrix [][]float64, opts ChordD
|
||||
if i == active {
|
||||
grow = 4
|
||||
}
|
||||
p.FillPath(internal.Sector(cx, cy, rad+2, rad+12+grow, arc[0], arc[1]), pal[i%len(pal)])
|
||||
p.FillPath(internal.Sector(cx, cy, rad+2, rad+12+grow, arc[0], arc[1], p), pal[i%len(pal)])
|
||||
if arc[1]-arc[0] > 0.15 {
|
||||
lx, ly := internal.Polar(cx, cy, rad+20, (arc[0]+arc[1])/2)
|
||||
ax := 0.5 - 0.5*float32(math.Sin((arc[0]+arc[1])/2))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
@@ -70,3 +71,41 @@ func TestChordDiagramSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestChordPlaceSelfRelation(t *testing.T) {
|
||||
l := chordPlace([][]float64{{3}})
|
||||
if len(l.pairs) != 1 || l.pairs[0] != [2]int{0, 0} {
|
||||
t.Fatalf("self relation %v", l.pairs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChordDiagramDenseDataBoundedAndDisabled(t *testing.T) {
|
||||
const n = 450
|
||||
names := make([]string, n)
|
||||
matrix := make([][]float64, n)
|
||||
for i := range matrix {
|
||||
names[i] = fmt.Sprint(i)
|
||||
matrix[i] = make([]float64, n)
|
||||
for j := range matrix[i] {
|
||||
matrix[i][j] = 1
|
||||
}
|
||||
}
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return ChordDiagram(c, names, matrix, ChordDiagramOptions{Frame: ChartFrame{Label: "Dense chords"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
shown := ui.Local(h.res.Element, "mujica.chord.visible", func() []int { return nil })
|
||||
if len(*shown) == 0 || len(*shown) > 360 {
|
||||
t.Fatalf("built %d chords", len(*shown))
|
||||
}
|
||||
tabTo(t, tt, "Dense chords")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if h.res.Point != n+n*(n+1)/2-1 || len(*shown) > 361 {
|
||||
t.Fatalf("last relation %d, built %d", h.res.Point, len(*shown))
|
||||
}
|
||||
h.disabled = true
|
||||
tt.Frame()
|
||||
if h.res.Point != -1 {
|
||||
t.Fatalf("disabled chord %d", h.res.Point)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
// DensityPlotOptions configure a DensityPlot.
|
||||
type DensityPlotOptions struct {
|
||||
Frame ChartFrame
|
||||
// Bandwidth is the Gaussian kernel's width; 0 uses Silverman's rule per group.
|
||||
// Bandwidth defaults to the per-group Gaussian estimate.
|
||||
Bandwidth float64
|
||||
// Points is how many x values the curves are sampled at; 0 means 128.
|
||||
// Points defaults to 128 samples per curve.
|
||||
Points int
|
||||
Format func(v float64) string
|
||||
}
|
||||
@@ -21,14 +21,17 @@ type DensityPlotOptions struct {
|
||||
func densitySample(values []float64) []float64 {
|
||||
var out []float64
|
||||
for _, v := range values {
|
||||
if !math.IsNaN(v) && !math.IsInf(v, 0) {
|
||||
if math.IsInf(v, 0) {
|
||||
panic("mujica: density values must be finite or NaN")
|
||||
}
|
||||
if !math.IsNaN(v) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// densityBandwidth is Silverman's rule of thumb, or 1 for flat samples.
|
||||
// densityBandwidth estimates spread, using 1 for flat samples.
|
||||
func densityBandwidth(v []float64) float64 {
|
||||
n := float64(len(v))
|
||||
mean := 0.0
|
||||
@@ -47,7 +50,7 @@ func densityBandwidth(v []float64) float64 {
|
||||
return 1.06 * sd * math.Pow(n, -0.2)
|
||||
}
|
||||
|
||||
// densityKDE estimates the density of v at each x with a Gaussian kernel of width h.
|
||||
// densityKDE estimates Gaussian density at xs.
|
||||
func densityKDE(v []float64, h float64, xs []float64) []float64 {
|
||||
out := make([]float64, len(xs))
|
||||
norm := 1 / (float64(len(v)) * h * math.Sqrt(2*math.Pi))
|
||||
@@ -62,7 +65,7 @@ func densityKDE(v []float64, h float64, xs []float64) []float64 {
|
||||
return out
|
||||
}
|
||||
|
||||
// densityCurves samples every group's density on one x grid, three bandwidths past the data.
|
||||
// densityCurves samples groups on a shared padded grid.
|
||||
func densityCurves(groups []BoxGroup, bandwidth float64, points int) (xs []float64, series []ChartSeries) {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
samples := make([][]float64, len(groups))
|
||||
@@ -97,9 +100,9 @@ func densityCurves(groups []BoxGroup, bandwidth float64, points int) (xs []float
|
||||
return xs, series
|
||||
}
|
||||
|
||||
// DensityPlot draws each group's kernel density curve over one shared axis.
|
||||
// DensityPlot overlays groups on a shared density axis.
|
||||
func DensityPlot(c *ui.Context, groups []BoxGroup, opts DensityPlotOptions) ChartResult {
|
||||
if opts.Bandwidth < 0 || opts.Points < 0 || opts.Points == 1 {
|
||||
if opts.Bandwidth < 0 || math.IsNaN(opts.Bandwidth) || math.IsInf(opts.Bandwidth, 0) || opts.Points < 0 || opts.Points == 1 {
|
||||
panic("mujica: DensityPlot needs Bandwidth ≥ 0 and Points of 0 or 2 or more")
|
||||
}
|
||||
points := opts.Points
|
||||
@@ -108,7 +111,12 @@ func DensityPlot(c *ui.Context, groups []BoxGroup, opts DensityPlotOptions) Char
|
||||
}
|
||||
ch := newChart(c, opts.Frame)
|
||||
ids := ""
|
||||
seen := map[string]bool{}
|
||||
for _, g := range groups {
|
||||
if g.Name == "" || seen[g.Name] {
|
||||
panic("mujica: DensityPlot groups need unique names")
|
||||
}
|
||||
seen[g.Name] = true
|
||||
ids += g.Name + chartID(g.Values) + ";"
|
||||
}
|
||||
type curves struct {
|
||||
|
||||
@@ -87,3 +87,35 @@ func TestDensityPlotSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDensityPlotFiniteBandwidthAndNames(t *testing.T) {
|
||||
charts2MustPanic(t, func(c *ui.Context) {
|
||||
DensityPlot(c, densityGroups(), DensityPlotOptions{Frame: ChartFrame{Label: "Density"}, Bandwidth: math.Inf(1)})
|
||||
})
|
||||
charts2MustPanic(t, func(c *ui.Context) {
|
||||
DensityPlot(c, []BoxGroup{{Name: "A"}, {Name: "A"}}, DensityPlotOptions{Frame: ChartFrame{Label: "Density"}})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDensityPlotLargeDataBoundedAndDisabled(t *testing.T) {
|
||||
values := make([]float64, 100_000)
|
||||
for i := range values {
|
||||
values[i] = float64(i % 100)
|
||||
}
|
||||
groups := []BoxGroup{{Name: "Sample", Values: values}}
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return DensityPlot(c, groups, DensityPlotOptions{Frame: ChartFrame{Label: "Large density"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Large density")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if h.res.Point != 127 {
|
||||
t.Fatalf("built sample index %d", h.res.Point)
|
||||
}
|
||||
h.disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if h.res.Point != -1 {
|
||||
t.Fatalf("disabled density %d", h.res.Point)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# 121 ChartLegend 图例
|
||||
|
||||
## 用途
|
||||
|
||||
展示序列名称与形状;点击或键盘切换序列显隐,支持横纵布局和折叠。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
hidden := map[string]bool{}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
k := mujica.Tokens(c)
|
||||
items := []mujica.ChartLegendItem{{Name: "Pews", Color: k.Info}, {Name: "Candles", Color: k.Success, Shape: 1}}
|
||||
mujica.ChartLegend(c, &hidden, items, mujica.ChartLegendOptions{MaxItems: 1})
|
||||
}
|
||||
ui.Render(view, 400, 200, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`hidden *map[string]bool`:名称映射到隐藏状态,nil 为静态图例。`items []ChartLegendItem`:唯一非空 `Name`、`Color`、`Shape`(0–4)。`Vertical` 默认 false;`MaxItems` 默认 0,显示全部,负值 panic。
|
||||
|
||||
## 状态
|
||||
|
||||
可见项显示形状,隐藏项显示空框与删除线;支持祖先禁用。折叠后显示展开按钮。
|
||||
|
||||
## 事件
|
||||
|
||||
`Toggled()` 返回本帧切换的序列名称和是否发生事件。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 进入图例;方向键在项之间移动;Space 切换显隐;展开按钮支持 Enter 和 Space。
|
||||
|
||||
## 限制
|
||||
|
||||
调用方依据 hidden 控制数据序列。图例颜色来自调用方,应使用主题色;形状提供颜色之外的识别。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 122 ChartTooltip 读数浮层
|
||||
|
||||
## 用途
|
||||
|
||||
在指针或键盘数据点旁显示多序列读数,自动避让窗口边缘。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
k := mujica.Tokens(c)
|
||||
mujica.ChartTooltip(c, 40, 40, []mujica.ChartTooltipLine{{Text: "Lauds"}, {Text: "Pews 5", Color: k.Info}, {Text: "Candles 2", Color: k.Success, Shape: 1}})
|
||||
}
|
||||
ui.Render(view, 400, 200, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`x, y float32`:窗口 DIP 坐标。`lines []ChartTooltipLine` 必须非空;`Text` 为读数,`Color` 为标记色,透明色隐藏标记;`Shape` 为标记形状。
|
||||
|
||||
## 状态
|
||||
|
||||
160 ms 进入过渡;减少动画直接出现。宽度和高度受窗口限制,长文字换行,内容可滚动。
|
||||
|
||||
## 事件
|
||||
|
||||
返回浮层 `*ui.Element`;读数事件由所属 Chart 提供。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
通过 `Chart` 使用时,方向键改变锚点,Escape 清除浮层;浮层内容沿用滚动容器操作。
|
||||
|
||||
## 限制
|
||||
|
||||
调用方传入当前读数和锚点,负责显示时机;所属图表应同时显示并朗读读数。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 123 ChartCrosshair 十字光标
|
||||
|
||||
## 用途
|
||||
|
||||
绘制横纵十字线和轴侧读数标签,提供最近数据点吸附。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
hair := mujica.ChartCrosshair(c, mujica.ChartCrosshairOptions{X: &x, Y: &y})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
hair.Draw(p, plot, x.Map(plot, 5), y.Map(plot, 70))
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`X, Y *ChartAxisPart`:至少提供一条轴。`Reach` 默认 24 DIP,负值 panic。返回 `ChartCrosshairPart`;`Snap(n, at, x, y)` 返回范围内最近索引,范围外为 -1。
|
||||
|
||||
## 状态
|
||||
|
||||
静态部件;随调用方传入的坐标变化。移动路径使用像素网格对齐。
|
||||
|
||||
## 事件
|
||||
|
||||
吸附返回索引;读数变化使用所属 `ChartResult.Changed()`。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
与 Chart 的 `Pick`、`Anchor`、`Readout` 组合,方向键逐点显示并朗读数值。
|
||||
|
||||
## 限制
|
||||
|
||||
`Draw` 使用窗口坐标,`Snap` 和 at 必须采用相同坐标系;轴部件在每帧创建。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 124 ChartAxis 坐标轴
|
||||
|
||||
## 用途
|
||||
|
||||
提供数值、时间、类别和对数刻度,以及值和像素之间的映射。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
axis := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Kind: mujica.AxisLog, Min: 1, Max: 1000})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
axis.Draw(p, ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`Kind` 默认 `AxisLinear`;`Side` 默认 `SideBottom`。数值与对数轴需要有限 `Min < Max`,对数轴 `Min > 0`。时间轴需要 `From < To`。类别轴需要非空 `Categories`。`Ticks` 为递增且位于范围内的指定刻度,nil 自动生成。`Format func(float64) string` 自定义标签。
|
||||
|
||||
## 状态
|
||||
|
||||
展示部件,继承当前主题、文本缩放和字体。
|
||||
|
||||
## 事件
|
||||
|
||||
`Map`、`Invert`、`Ticks`、`Label` 返回映射与标签;组件自身没有业务事件。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
所属 Chart 负责聚焦、方向键和读数。坐标轴作为绘图部件随图表展示。
|
||||
|
||||
## 限制
|
||||
|
||||
为标签预留边距。时间值采用 Unix 秒,默认格式使用 UTC;需要本地时区时通过 Format 格式化。数据和刻度切片在使用期间保持稳定。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 125 ChartGrid 图表网格
|
||||
|
||||
## 用途
|
||||
|
||||
按坐标轴主刻度和次刻度绘制对齐设备像素的网格线。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
grid := mujica.ChartGrid(c, mujica.ChartGridOptions{X: &x, Y: &y, Minor: 2})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
grid.Draw(p, plot)
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`X, Y *ChartAxisPart` 至少一条轴;`Minor` 默认 0,正整数表示每个主刻度间隔的等分数。类别轴的 Minor 必须为 0。负值 panic。
|
||||
|
||||
## 状态
|
||||
|
||||
展示部件;主网格使用 Border,次网格使用较淡的 Border。
|
||||
|
||||
## 事件
|
||||
|
||||
返回 `ChartGridPart`,由 `Draw(p, plot)` 绘制。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
所属图表的键盘操作保持生效;网格随图表展示。
|
||||
|
||||
## 限制
|
||||
|
||||
对数轴次刻度按对数距离等分。大范围刻度按绘图像素密度抽样绘制。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 126 ChartBrush 区间刷选
|
||||
|
||||
## 用途
|
||||
|
||||
选择图表可见区间,支持移动窗口、调整两端和拖动新建范围。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
value := [2]float64{20, 60}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChartBrush(c, &value, mujica.ChartBrushOptions{Label: "Months", Max: 100, Step: 1, Values: []float64{2, 4, 3, 8, 5}})
|
||||
}
|
||||
ui.Render(view, 400, 160, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`value *[2]float64` 为域内严格递增区间;`Label` 必填;有限 `Min < Max`。`Step` 默认跨度的 1/100,负值 panic。`Values` 为预览折线。`Height` 默认 48 DIP,显式高度至少 28 DIP。`Format` 自定义值文本。`Disabled` 默认 false。
|
||||
|
||||
## 状态
|
||||
|
||||
显示选区边框与两端手柄;自身或祖先禁用时保持区间。拖动窗口到边缘保持跨度。
|
||||
|
||||
## 事件
|
||||
|
||||
返回 `ChoiceResult`;`Changed()` 表示区间变化;新范围会被朗读,调用方据此调整图表范围。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 按起点、窗口、终点顺序聚焦;方向键移动一个步长;手柄 PageUp/PageDown 移动十个步长;Home/End 到域边界。
|
||||
|
||||
## 限制
|
||||
|
||||
范围至少保留一个 Step。预览切片按身份缓存,更新数据时替换切片。调用方负责图表缩放和查询。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 127 ChartAnnotation 图表标注
|
||||
|
||||
## 用途
|
||||
|
||||
在图表上显示水平线、垂直线、区间带和文字标注,可选择开启移动。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
notes := []mujica.ChartNote{{Kind: mujica.NoteHLine, Value: 50, Text: "Goal", Draggable: true}, {Kind: mujica.NoteBand, Value: 6, End: 8, Text: "Lent"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
annotation := mujica.ChartAnnotation(c, notes, mujica.ChartAnnotationOptions{X: &x, Y: &y})
|
||||
box := ui.Box(c).Fill().Focusable().Label("Annotations")
|
||||
b := box.Bounds()
|
||||
annotation.Drag(box, ui.Rect{X: 60, Y: 20, W: b.W - 80, H: b.H - 60})
|
||||
box.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
annotation.Draw(p, plot)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`notes []ChartNote`:`Kind` 默认为 `NoteHLine`;`Value` 是水平线 Y 或其他标注 X;`End` 是带末端 X 或文字 Y;`Text` 是文案;`Draggable` 默认 false。`X, Y` 均必填;坐标必须有限,带需要 End > Value,文字需要非空 Text。
|
||||
|
||||
## 状态
|
||||
|
||||
先绘制区间带,再绘制线和文字;可移动线带有菱形手柄。祖先禁用阻止移动。
|
||||
|
||||
## 事件
|
||||
|
||||
`Drag(element, plot)` 处理指针和键盘,修改原切片并返回是否发生变化。`Draw` 绘制当前标注。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦宿主图表;Alt+PageUp/PageDown 选择可移动标注;Alt+方向键每次移动轴跨度的 1/100。选中说明和变化会被朗读。
|
||||
|
||||
## 限制
|
||||
|
||||
Drag 的 plot 是元素局部坐标;Draw 的 plot 是窗口坐标。标注切片由调用方持有;移动后调用方负责业务保存。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 128 ChartExport 图表导出
|
||||
|
||||
## 用途
|
||||
|
||||
以当前窗口主题离屏绘制图表,返回 PNG 字节,供调用方保存或传输。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
data, err := mujica.ChartExport(c, func(c *ui.Context) {
|
||||
mujica.SparkBar(c, []float64{3, -2, 5}, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}})
|
||||
}, mujica.ChartExportOptions{Width: 300, Height: 100, Scale: 2})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
bitmap, err := ui.DecodeBitmap(data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ui.Image(c, bitmap).Size(300, 100)
|
||||
}
|
||||
ui.Render(view, 400, 160, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`chart func(*ui.Context)` 必填;`Width, Height int` 为正 DIP 尺寸;有限 `Scale float32` 默认 2,负值 panic。
|
||||
|
||||
## 状态
|
||||
|
||||
沿用当前主题、密度、本地化、自定义 token 和桌面偏好。导出过程为同步渲染。
|
||||
|
||||
## 事件
|
||||
|
||||
返回 `([]byte, error)`;PNG 编码错误直接返回调用方。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
导出触发按钮由调用方提供,支持 Enter 和 Space。
|
||||
|
||||
## 限制
|
||||
|
||||
图表回调应独立渲染,复用调用方数据;调用方在用户触发导出时调用以控制开销。组件只返回字节。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 129 ViolinPlot 小提琴图
|
||||
|
||||
## 用途
|
||||
|
||||
比较多组样本的核密度轮廓,中位数以菱形标记,四分位以粗条表示。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
groups := []mujica.BoxGroup{{Name: "Choir", Values: []float64{1, 2, 3, 4, 7, 9}}, {Name: "Nave", Values: []float64{2, 4, 5, 6, 8}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ViolinPlot(c, groups, mujica.ViolinPlotOptions{Frame: mujica.ChartFrame{Label: "Hymns"}, Bandwidth: 1})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`groups []BoxGroup`:唯一非空 Name 与样本 Values;NaN 为缺值,无穷值 panic。`Frame` 指定名称、高度、状态和占位文案。有限 `Bandwidth` 默认 0,按组自动估计;负值 panic。`Format` 自定义数值读数。
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停、键盘读数、加载、错误、空数据、祖先禁用。单值或相同值样本保留密度轮廓。
|
||||
|
||||
## 事件
|
||||
|
||||
`ChartResult.Point` 为组索引;`Changed()` 表示读数组变化;`Retried()` 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦;方向键逐组读取中位数和四分位;Home/End 首尾;Escape 清除读数。
|
||||
|
||||
## 限制
|
||||
|
||||
每组轮廓使用 64 个采样点;大量组按像素位置抽样绘制,当前读数组保留。排序和核密度在样本切片身份或带宽变化时计算;更新数据需替换切片。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 130 ParallelCoordinates 平行坐标
|
||||
|
||||
## 用途
|
||||
|
||||
在多条平行轴间展示每行数值;轴上刷选筛除行,当前读数行用粗线高亮。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
filters := map[string][2]float64{}
|
||||
axes := []string{"Age", "Hymns", "Vigils"}
|
||||
rows := [][]float64{{20, 3, 9}, {35, 8, 2}, {50, 5, 5}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ParallelCoordinates(c, &filters, axes, rows, mujica.ParallelCoordinatesOptions{Frame: mujica.ChartFrame{Label: "Monks"}, Names: []string{"Agnes", "Clare", "Hild"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`filters *map[string][2]float64` 必填,键为轴名,值为有限递增范围。`axes []string` 至少两个唯一非空名称;每行长度与轴数一致。NaN 为缺值。`Names` 为逐行名称,nil 显示序号。`Frame` 与 `Format` 同其他图表。
|
||||
|
||||
## 状态
|
||||
|
||||
默认、高亮、筛除、加载、错误、空数据、祖先禁用;筛除行的读数附带本地化文字。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()` 表示读数行变化;`ValueChanged()` 表示刷选范围变化;`Retried()` 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦;方向键逐行读数;Home/End 首尾。Alt+PageUp/PageDown 选择轴;Alt+上下调整下端;Alt+Shift+上下调整上端;Alt+Delete 清除该轴刷选。
|
||||
|
||||
## 限制
|
||||
|
||||
指针拖动轴设置范围,轴上点击清除范围。100,000 行绘制数量受图表高度约束,键盘可读取全部行。范围与过滤仅在数据或筛选变化时计算;更新数据需替换外层 rows 切片。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 131 ChordDiagram 弦图
|
||||
|
||||
## 用途
|
||||
|
||||
以组弧和弦带展示关系矩阵,悬停或键盘读数组时高亮相关连线。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
names := []string{"Nave", "Choir", "Crypt"}
|
||||
matrix := [][]float64{{0, 5, 2}, {4, 0, 1}, {1, 2, 0}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChordDiagram(c, names, matrix, mujica.ChordDiagramOptions{Frame: mujica.ChartFrame{Label: "Paths"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`names []string` 为唯一非空组名;`matrix [][]float64` 为同阶方阵,数值须有限且非负。`Frame` 控制名称、尺寸和加载状态。`Format` 自定义关系数值。
|
||||
|
||||
## 状态
|
||||
|
||||
默认、组高亮、弦高亮、加载、错误、空矩阵、祖先禁用;对角线值形成自身关系。
|
||||
|
||||
## 事件
|
||||
|
||||
`Point` 先枚举组,再枚举关系;`Changed()` 表示读数变化;`Retried()` 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦;方向键依次读取组的流入流出与关系值;Home/End 首尾;Escape 清除。
|
||||
|
||||
## 限制
|
||||
|
||||
高密度矩阵按绘图区半径抽样绘制弦带,当前键盘关系保留;布局仅在外层 matrix 切片身份变化时计算。更新数据需替换外层切片。组弧标签在有足够空间时展示,完整名称始终包含在键盘读数中。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 132 SparkBar 迷你柱
|
||||
|
||||
## 用途
|
||||
|
||||
行内展示正负数值柱,零线区分方向,末项以强调颜色和菱形标记。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
values := []float64{3, -2, 4, 1, -3}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.SparkBar(c, values, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}, BarWidth: 10})
|
||||
}
|
||||
ui.Render(view, 400, 100, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`values []float64`:有限数值,NaN 表示缺值。`Frame.Label` 必填;`Frame.Height` 默认 28 DIP。`Frame.Status`、`Error`、`Empty` 控制占位。`BarWidth` 默认 6 DIP,负值 panic;整体宽度受窗口限制。`Format` 自定义读数。
|
||||
|
||||
## 状态
|
||||
|
||||
默认显示末值;悬停或聚焦显示当前点。加载、错误与空数据保持绘图区尺寸;祖先禁用阻止操作。
|
||||
|
||||
## 事件
|
||||
|
||||
返回 `ChartResult`,`Point` 为当前索引,`Changed()` 表示读数变化,`Retried()` 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦;左右箭头逐点读数并朗读;Home/End 到首尾;Escape 恢复末值。
|
||||
|
||||
## 限制
|
||||
|
||||
大数据按像素列保留首、极值和末项,绘制数量受宽度约束。数据范围与抽样缓存按切片身份更新;更换数据时替换切片。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 133 TrendIndicator 趋势指示
|
||||
|
||||
## 用途
|
||||
|
||||
以方向箭头、带符号数值和无障碍方向文字展示指标变化。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.TrendIndicator(c, 12.5, mujica.TrendIndicatorOptions{Precision: 1, Unit: "%", Note: "vs Lent"})
|
||||
}
|
||||
ui.Render(view, 400, 100, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`change float64` 必须有限;`Precision` 默认 0,负值 panic;`Unit` 和 `Note` 默认空;`Invert` 默认 false,开启后下降采用成功语义;`Format` 替换数值格式。
|
||||
|
||||
## 状态
|
||||
|
||||
上升、下降、持平;正负号与箭头提供颜色之外的区分;方向词支持中英文和消息覆盖。
|
||||
|
||||
## 事件
|
||||
|
||||
返回 `*ui.Element`,展示调用方提供的变化值。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
展示部件;辅助技术读取方向和完整变化值。
|
||||
|
||||
## 限制
|
||||
|
||||
变化值由调用方计算;组件提供显示和语义颜色。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 138 DensityPlot 密度图
|
||||
|
||||
## 用途
|
||||
|
||||
将多组样本的高斯核密度曲线叠加在同一坐标域,以形状和颜色区分序列。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
groups := []mujica.BoxGroup{{Name: "Matins", Values: []float64{1, 2, 2, 3, 7}}, {Name: "Vespers", Values: []float64{3, 4, 5, 6, 6}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DensityPlot(c, groups, mujica.DensityPlotOptions{Frame: mujica.ChartFrame{Label: "Ages"}, Bandwidth: 1, Points: 128})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`groups []BoxGroup` 为唯一非空组名和样本;NaN 为缺值。有限 `Bandwidth` 默认 0,按组自动估计;负值 panic。`Points` 默认 128,显式值须至少 2。`Frame` 和 `Format` 控制图表与密度读数。
|
||||
|
||||
## 状态
|
||||
|
||||
默认、序列显隐、悬停、键盘读数、加载、错误、空数据、祖先禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()` 表示采样位置变化;`Toggled()` 表示序列显隐;`Retried()` 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 先到图例,再到图表;图例 Space 切换显隐;图表方向键读取共享 X 位置的各组密度;Home/End 首尾;Escape 清除。
|
||||
|
||||
## 限制
|
||||
|
||||
核密度计算在数据、带宽或 Points 变化时执行;绘制曲线按像素列抽样。100,000 个样本默认生成每组 128 个密度点。更新数据需替换样本切片。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 140 ChartEmptyState 图表占位
|
||||
|
||||
## 用途
|
||||
|
||||
在图表无数据、加载或失败时保持图表尺寸,并在失败时提供重试。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChartEmptyState(c, mujica.DataFailed, mujica.ChartEmptyStateOptions{Height: 180, Error: "The archive did not answer"})
|
||||
}
|
||||
ui.Render(view, 400, 240, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`status DataStatus`:DataReady 显示空数据,DataLoading 显示加载,DataFailed 显示错误和重试;其他有效加载状态同样显示加载。`Height` 默认 240 DIP,负值 panic;`Error`、`Empty` 默认内置本地化文案。
|
||||
|
||||
## 状态
|
||||
|
||||
空数据、加载、错误;祖先禁用保持展示并阻止重试;减少动画显示静态加载说明。
|
||||
|
||||
## 事件
|
||||
|
||||
返回 `ChoiceResult`,`Changed()` 表示点击重试,数据请求由调用方执行。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦重试;Enter 或 Space 触发一次重试。
|
||||
|
||||
## 限制
|
||||
|
||||
只管理占位展示和事件;数据和异步状态由调用方维护。
|
||||
@@ -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 请求用户输入;问题、选项与自由回答;超时提示。 |
|
||||
@@ -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 | 远程光标与选区标记;姓名标签。 |
|
||||
@@ -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 | 活动流;参与者、动作、对象、时间;按日分组。 |
|
||||
@@ -0,0 +1,180 @@
|
||||
package mujica_test
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func ExampleChartLegend() {
|
||||
hidden := map[string]bool{}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
k := mujica.Tokens(c)
|
||||
items := []mujica.ChartLegendItem{{Name: "Pews", Color: k.Info}, {Name: "Candles", Color: k.Success, Shape: 1}}
|
||||
mujica.ChartLegend(c, &hidden, items, mujica.ChartLegendOptions{MaxItems: 1})
|
||||
}
|
||||
ui.Render(view, 400, 200, 1)
|
||||
}
|
||||
|
||||
func ExampleChartTooltip() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
k := mujica.Tokens(c)
|
||||
mujica.ChartTooltip(c, 40, 40, []mujica.ChartTooltipLine{{Text: "Lauds"}, {Text: "Pews 5", Color: k.Info}, {Text: "Candles 2", Color: k.Success, Shape: 1}})
|
||||
}
|
||||
ui.Render(view, 400, 200, 1)
|
||||
}
|
||||
|
||||
func ExampleChartCrosshair() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
hair := mujica.ChartCrosshair(c, mujica.ChartCrosshairOptions{X: &x, Y: &y})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
hair.Draw(p, plot, x.Map(plot, 5), y.Map(plot, 70))
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleChartAxis() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
axis := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Kind: mujica.AxisLog, Min: 1, Max: 1000})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
axis.Draw(p, ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleChartGrid() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
grid := mujica.ChartGrid(c, mujica.ChartGridOptions{X: &x, Y: &y, Minor: 2})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
grid.Draw(p, plot)
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleChartBrush() {
|
||||
value := [2]float64{20, 60}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChartBrush(c, &value, mujica.ChartBrushOptions{Label: "Months", Max: 100, Step: 1, Values: []float64{2, 4, 3, 8, 5}})
|
||||
}
|
||||
ui.Render(view, 400, 160, 1)
|
||||
}
|
||||
|
||||
func ExampleChartAnnotation() {
|
||||
notes := []mujica.ChartNote{{Kind: mujica.NoteHLine, Value: 50, Text: "Goal", Draggable: true}, {Kind: mujica.NoteBand, Value: 6, End: 8, Text: "Lent"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
annotation := mujica.ChartAnnotation(c, notes, mujica.ChartAnnotationOptions{X: &x, Y: &y})
|
||||
box := ui.Box(c).Fill().Focusable().Label("Annotations")
|
||||
b := box.Bounds()
|
||||
annotation.Drag(box, ui.Rect{X: 60, Y: 20, W: b.W - 80, H: b.H - 60})
|
||||
box.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
annotation.Draw(p, plot)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleChartExport() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
data, err := mujica.ChartExport(c, func(c *ui.Context) {
|
||||
mujica.SparkBar(c, []float64{3, -2, 5}, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}})
|
||||
}, mujica.ChartExportOptions{Width: 300, Height: 100, Scale: 2})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
bitmap, err := ui.DecodeBitmap(data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ui.Image(c, bitmap).Size(300, 100)
|
||||
}
|
||||
ui.Render(view, 400, 160, 1)
|
||||
}
|
||||
|
||||
func ExampleSparkBar() {
|
||||
values := []float64{3, -2, 4, 1, -3}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.SparkBar(c, values, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}, BarWidth: 10})
|
||||
}
|
||||
ui.Render(view, 400, 100, 1)
|
||||
}
|
||||
|
||||
func ExampleTrendIndicator() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.TrendIndicator(c, 12.5, mujica.TrendIndicatorOptions{Precision: 1, Unit: "%", Note: "vs Lent"})
|
||||
}
|
||||
ui.Render(view, 400, 100, 1)
|
||||
}
|
||||
|
||||
func ExampleChartEmptyState() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChartEmptyState(c, mujica.DataFailed, mujica.ChartEmptyStateOptions{Height: 180, Error: "The archive did not answer"})
|
||||
}
|
||||
ui.Render(view, 400, 240, 1)
|
||||
}
|
||||
|
||||
func ExampleViolinPlot() {
|
||||
groups := []mujica.BoxGroup{{Name: "Choir", Values: []float64{1, 2, 3, 4, 7, 9}}, {Name: "Nave", Values: []float64{2, 4, 5, 6, 8}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ViolinPlot(c, groups, mujica.ViolinPlotOptions{Frame: mujica.ChartFrame{Label: "Hymns"}, Bandwidth: 1})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleParallelCoordinates() {
|
||||
filters := map[string][2]float64{}
|
||||
axes := []string{"Age", "Hymns", "Vigils"}
|
||||
rows := [][]float64{{20, 3, 9}, {35, 8, 2}, {50, 5, 5}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ParallelCoordinates(c, &filters, axes, rows, mujica.ParallelCoordinatesOptions{Frame: mujica.ChartFrame{Label: "Monks"}, Names: []string{"Agnes", "Clare", "Hild"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleChordDiagram() {
|
||||
names := []string{"Nave", "Choir", "Crypt"}
|
||||
matrix := [][]float64{{0, 5, 2}, {4, 0, 1}, {1, 2, 0}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChordDiagram(c, names, matrix, mujica.ChordDiagramOptions{Frame: mujica.ChartFrame{Label: "Paths"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleDensityPlot() {
|
||||
groups := []mujica.BoxGroup{{Name: "Matins", Values: []float64{1, 2, 2, 3, 7}}, {Name: "Vespers", Values: []float64{3, 4, 5, 6, 6}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DensityPlot(c, groups, mujica.DensityPlotOptions{Frame: mujica.ChartFrame{Label: "Ages"}, Bandwidth: 1, Points: 128})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -1,3 +1,70 @@
|
||||
package main
|
||||
|
||||
var charts2Pages = []page{}
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
const c2Category = "Charts II"
|
||||
|
||||
var charts2Pages = []page{
|
||||
{ID: "121", Name: "ChartLegend", ZhName: "图例", Category: c2Category, Demo: c2LegendDemo, Code: c2ChartLegendCode, Keys: "Tab enters; arrows move; Space toggles; Enter expands."},
|
||||
{ID: "122", Name: "ChartTooltip", ZhName: "读数浮层", Category: c2Category, Demo: c2TooltipDemo, Code: c2ChartTooltipCode, Keys: c1Keys},
|
||||
{ID: "123", Name: "ChartCrosshair", ZhName: "十字光标", Category: c2Category, Demo: c2CrosshairDemo, Code: c2ChartCrosshairCode, Keys: c1Keys},
|
||||
{ID: "124", Name: "ChartAxis", ZhName: "坐标轴", Category: c2Category, Demo: c2AxisDemo, Code: c2ChartAxisCode, Keys: c1Keys},
|
||||
{ID: "125", Name: "ChartGrid", ZhName: "图表网格", Category: c2Category, Demo: c2GridDemo, Code: c2ChartGridCode, Keys: c1Keys},
|
||||
{ID: "126", Name: "ChartBrush", ZhName: "区间刷选", Category: c2Category, Demo: c2BrushDemo, Code: c2ChartBrushCode, Keys: "Tab: start, window, end; arrows step; PageUp/Down step ten; Home/End reach bounds."},
|
||||
{ID: "127", Name: "ChartAnnotation", ZhName: "图表标注", Category: c2Category, Demo: c2AnnotationDemo, Code: c2ChartAnnotationCode, Keys: c1Keys + " Alt+PageUp/Down selects a movable note; Alt+arrows move it."},
|
||||
{ID: "128", Name: "ChartExport", ZhName: "图表导出", Category: c2Category, Demo: c2ExportDemo, Code: c2ChartExportCode, Keys: "Tab reaches Export PNG; Enter or Space renders bytes in the current theme."},
|
||||
{ID: "129", Name: "ViolinPlot", ZhName: "小提琴图", Category: c2Category, Demo: c2ViolinDemo, Code: c2ViolinPlotCode, Keys: c1Keys},
|
||||
{ID: "130", Name: "ParallelCoordinates", ZhName: "平行坐标", Category: c2Category, Demo: c2ParallelDemo, Code: c2ParallelCoordinatesCode, Keys: c1Keys + " Alt+PageUp/Down selects an axis; Alt+Up/Down adjusts its lower bound; Alt+Shift+Up/Down adjusts its upper bound; Alt+Delete clears."},
|
||||
{ID: "131", Name: "ChordDiagram", ZhName: "弦图", Category: c2Category, Demo: c2ChordDemo, Code: c2ChordDiagramCode, Keys: c1Keys},
|
||||
{ID: "132", Name: "SparkBar", ZhName: "迷你柱", Category: c2Category, Demo: c2SparkBarDemo, Code: c2SparkBarCode, Keys: "Tab focuses bars; Left/Right read points; Home/End jump; Escape restores the last value."},
|
||||
{ID: "133", Name: "TrendIndicator", ZhName: "趋势指示", Category: c2Category, Demo: c2TrendDemo, Code: c2TrendIndicatorCode, Keys: "Change and Invert controls use the keyboard; the indicator is read as text."},
|
||||
{ID: "138", Name: "DensityPlot", ZhName: "密度图", Category: c2Category, Demo: c2DensityDemo, Code: c2DensityPlotCode, Keys: c1Keys + " Space on legend entries hides or shows a group."},
|
||||
{ID: "140", Name: "ChartEmptyState", ZhName: "图表占位", Category: c2Category, Demo: c2EmptyDemo, Code: c2ChartEmptyStateCode, Keys: "Tab reaches Retry; Enter or Space retries."},
|
||||
}
|
||||
|
||||
func c2Check(c *ui.Context, value *bool, label string, log func(string)) {
|
||||
if mujica.Checkbox(c, value, label, mujica.CheckboxOptions{}).Changed() {
|
||||
log(fmt.Sprintf("%s = %v", label, *value))
|
||||
}
|
||||
}
|
||||
|
||||
func c2Number(c *ui.Context, value *float64, label string, lo, hi, step float64, log func(string)) {
|
||||
ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, label)
|
||||
r := mujica.NumberInput(c, value, mujica.NumberInputOptions{Label: label, Min: lo, Max: hi, Step: step})
|
||||
r.Element.Width(150)
|
||||
if r.Changed() {
|
||||
log(fmt.Sprintf("%s = %g", label, *value))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func c2Select(c *ui.Context, value *string, label string, choices []string, log func(string)) {
|
||||
items := make([]mujica.SelectOption[string], len(choices))
|
||||
for i, s := range choices {
|
||||
items[i] = mujica.SelectOption[string]{Value: s, Label: s}
|
||||
}
|
||||
r := mujica.Select(c, value, items, mujica.SelectOptions{Label: label})
|
||||
r.Element.Width(160)
|
||||
if r.Changed() {
|
||||
log(label + " = " + *value)
|
||||
}
|
||||
}
|
||||
|
||||
func c2Frame(c *ui.Context, label string, status *string, log func(string)) (mujica.ChartFrame, bool) {
|
||||
c2Select(c, status, "Status", c1Statuses, log)
|
||||
f := mujica.ChartFrame{Label: label}
|
||||
if *status == "Loading" {
|
||||
f.Status = mujica.DataLoading
|
||||
}
|
||||
if *status == "Failed" {
|
||||
f.Status, f.Error = mujica.DataFailed, "The archive did not answer"
|
||||
}
|
||||
return f, *status != "Empty"
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
const c2ChartLegendCode = `hidden := map[string]bool{}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
k := mujica.Tokens(c)
|
||||
items := []mujica.ChartLegendItem{{Name: "Pews", Color: k.Info}, {Name: "Candles", Color: k.Success, Shape: 1}}
|
||||
mujica.ChartLegend(c, &hidden, items, mujica.ChartLegendOptions{MaxItems: 1})
|
||||
}
|
||||
ui.Render(view, 400, 200, 1)`
|
||||
|
||||
const c2ChartTooltipCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
k := mujica.Tokens(c)
|
||||
mujica.ChartTooltip(c, 40, 40, []mujica.ChartTooltipLine{{Text: "Lauds"}, {Text: "Pews 5", Color: k.Info}, {Text: "Candles 2", Color: k.Success, Shape: 1}})
|
||||
}
|
||||
ui.Render(view, 400, 200, 1)`
|
||||
|
||||
const c2ChartCrosshairCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
hair := mujica.ChartCrosshair(c, mujica.ChartCrosshairOptions{X: &x, Y: &y})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
hair.Draw(p, plot, x.Map(plot, 5), y.Map(plot, 70))
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const c2ChartAxisCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
axis := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Kind: mujica.AxisLog, Min: 1, Max: 1000})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
axis.Draw(p, ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const c2ChartGridCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
grid := mujica.ChartGrid(c, mujica.ChartGridOptions{X: &x, Y: &y, Minor: 2})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
grid.Draw(p, plot)
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const c2ChartBrushCode = `value := [2]float64{20, 60}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChartBrush(c, &value, mujica.ChartBrushOptions{Label: "Months", Max: 100, Step: 1, Values: []float64{2, 4, 3, 8, 5}})
|
||||
}
|
||||
ui.Render(view, 400, 160, 1)`
|
||||
|
||||
const c2ChartAnnotationCode = `notes := []mujica.ChartNote{{Kind: mujica.NoteHLine, Value: 50, Text: "Goal", Draggable: true}, {Kind: mujica.NoteBand, Value: 6, End: 8, Text: "Lent"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
annotation := mujica.ChartAnnotation(c, notes, mujica.ChartAnnotationOptions{X: &x, Y: &y})
|
||||
box := ui.Box(c).Fill().Focusable().Label("Annotations")
|
||||
b := box.Bounds()
|
||||
annotation.Drag(box, ui.Rect{X: 60, Y: 20, W: b.W - 80, H: b.H - 60})
|
||||
box.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
annotation.Draw(p, plot)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const c2ChartExportCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
data, err := mujica.ChartExport(c, func(c *ui.Context) {
|
||||
mujica.SparkBar(c, []float64{3, -2, 5}, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}})
|
||||
}, mujica.ChartExportOptions{Width: 300, Height: 100, Scale: 2})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
bitmap, err := ui.DecodeBitmap(data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ui.Image(c, bitmap).Size(300, 100)
|
||||
}
|
||||
ui.Render(view, 400, 160, 1)`
|
||||
|
||||
const c2SparkBarCode = `values := []float64{3, -2, 4, 1, -3}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.SparkBar(c, values, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}, BarWidth: 10})
|
||||
}
|
||||
ui.Render(view, 400, 100, 1)`
|
||||
|
||||
const c2TrendIndicatorCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.TrendIndicator(c, 12.5, mujica.TrendIndicatorOptions{Precision: 1, Unit: "%", Note: "vs Lent"})
|
||||
}
|
||||
ui.Render(view, 400, 100, 1)`
|
||||
|
||||
const c2ChartEmptyStateCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChartEmptyState(c, mujica.DataFailed, mujica.ChartEmptyStateOptions{Height: 180, Error: "The archive did not answer"})
|
||||
}
|
||||
ui.Render(view, 400, 240, 1)`
|
||||
|
||||
const c2ViolinPlotCode = `groups := []mujica.BoxGroup{{Name: "Choir", Values: []float64{1, 2, 3, 4, 7, 9}}, {Name: "Nave", Values: []float64{2, 4, 5, 6, 8}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ViolinPlot(c, groups, mujica.ViolinPlotOptions{Frame: mujica.ChartFrame{Label: "Hymns"}, Bandwidth: 1})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const c2ParallelCoordinatesCode = `filters := map[string][2]float64{}
|
||||
axes := []string{"Age", "Hymns", "Vigils"}
|
||||
rows := [][]float64{{20, 3, 9}, {35, 8, 2}, {50, 5, 5}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ParallelCoordinates(c, &filters, axes, rows, mujica.ParallelCoordinatesOptions{Frame: mujica.ChartFrame{Label: "Monks"}, Names: []string{"Agnes", "Clare", "Hild"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const c2ChordDiagramCode = `names := []string{"Nave", "Choir", "Crypt"}
|
||||
matrix := [][]float64{{0, 5, 2}, {4, 0, 1}, {1, 2, 0}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChordDiagram(c, names, matrix, mujica.ChordDiagramOptions{Frame: mujica.ChartFrame{Label: "Paths"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const c2DensityPlotCode = `groups := []mujica.BoxGroup{{Name: "Matins", Values: []float64{1, 2, 2, 3, 7}}, {Name: "Vespers", Values: []float64{3, 4, 5, 6, 6}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DensityPlot(c, groups, mujica.DensityPlotOptions{Frame: mujica.ChartFrame{Label: "Ages"}, Bandwidth: 1, Points: 128})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func c2ViolinDemo(c *ui.Context, log func(string)) { c2DistributionDemo(c, log, "129", "ViolinPlot") }
|
||||
func c2DensityDemo(c *ui.Context, log func(string)) { c2DistributionDemo(c, log, "138", "DensityPlot") }
|
||||
|
||||
func c2DistributionDemo(c *ui.Context, log func(string), id, name string) {
|
||||
type state struct {
|
||||
status string
|
||||
bandwidth float64
|
||||
disabled bool
|
||||
groups []mujica.BoxGroup
|
||||
}
|
||||
s := ui.Local(c.Root(), id, func() state {
|
||||
return state{status: "Ready", groups: []mujica.BoxGroup{
|
||||
{Name: "Matins", Values: []float64{1, 2, 2, 3, 4, 5, 8, 9}},
|
||||
{Name: "Vespers", Values: []float64{3, 4, 5, 6, 6, 7, 8, 11}},
|
||||
}}
|
||||
})
|
||||
c1Section(c, name, func() {
|
||||
c2Number(c, &s.bandwidth, "Bandwidth", 0, 5, 0.5, log)
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
}, func() {
|
||||
f, show := c2Frame(c, name, &s.status, log)
|
||||
groups := s.groups
|
||||
if !show {
|
||||
groups = nil
|
||||
}
|
||||
ui.Column(c).Disabled(s.disabled).Children(func() {
|
||||
if id == "129" {
|
||||
c1Log(log, name, mujica.ViolinPlot(c, groups, mujica.ViolinPlotOptions{Frame: f, Bandwidth: s.bandwidth}))
|
||||
} else {
|
||||
c1Log(log, name, mujica.DensityPlot(c, groups, mujica.DensityPlotOptions{Frame: f, Bandwidth: s.bandwidth}))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func c2ParallelDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
status string
|
||||
disabled bool
|
||||
filters map[string][2]float64
|
||||
rows [][]float64
|
||||
}
|
||||
s := ui.Local(c.Root(), "130", func() state {
|
||||
return state{status: "Ready", filters: map[string][2]float64{}, rows: [][]float64{{20, 3, 9}, {35, 8, 2}, {50, 5, 5}, {65, 1, 4}}}
|
||||
})
|
||||
c1Section(c, "ParallelCoordinates", func() {
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
if mujica.Button(c, "Clear filters", mujica.ButtonOptions{Disabled: s.disabled}).Clicked() {
|
||||
s.filters = map[string][2]float64{}
|
||||
log("ParallelCoordinates: clear filters")
|
||||
}
|
||||
}, func() {
|
||||
f, show := c2Frame(c, "ParallelCoordinates", &s.status, log)
|
||||
rows := s.rows
|
||||
names := []string{"Agnes", "Clare", "Hild", "Bede"}
|
||||
if !show {
|
||||
rows = nil
|
||||
names = nil
|
||||
}
|
||||
ui.Column(c).Disabled(s.disabled).Children(func() {
|
||||
c1Log(log, "ParallelCoordinates", mujica.ParallelCoordinates(c, &s.filters, []string{"Age", "Hymns", "Vigils"}, rows, mujica.ParallelCoordinatesOptions{Frame: f, Names: names}))
|
||||
})
|
||||
ui.Text(c, fmt.Sprintf("Filters: %v", s.filters))
|
||||
})
|
||||
}
|
||||
|
||||
func c2ChordDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
status string
|
||||
disabled bool
|
||||
amount float64
|
||||
matrix [][]float64
|
||||
}
|
||||
s := ui.Local(c.Root(), "131", func() state {
|
||||
return state{status: "Ready", amount: 5, matrix: [][]float64{{0, 5, 2}, {4, 0, 1}, {1, 2, 0}}}
|
||||
})
|
||||
c1Section(c, "ChordDiagram", func() {
|
||||
old := s.amount
|
||||
c2Number(c, &s.amount, "Nave to Choir", 0, 20, 1, log)
|
||||
if old != s.amount {
|
||||
s.matrix = [][]float64{{0, s.amount, 2}, {4, 0, 1}, {1, 2, 0}}
|
||||
}
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
}, func() {
|
||||
f, show := c2Frame(c, "ChordDiagram", &s.status, log)
|
||||
names, matrix := []string{"Nave", "Choir", "Crypt"}, s.matrix
|
||||
if !show {
|
||||
names = nil
|
||||
matrix = nil
|
||||
}
|
||||
ui.Column(c).Disabled(s.disabled).Children(func() {
|
||||
c1Log(log, "ChordDiagram", mujica.ChordDiagram(c, names, matrix, mujica.ChordDiagramOptions{Frame: f}))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
type c2PartsState struct {
|
||||
status, kind string
|
||||
disabled, crosshair, draggable, fixed bool
|
||||
minor float64
|
||||
notes []mujica.ChartNote
|
||||
}
|
||||
|
||||
func c2TooltipDemo(c *ui.Context, log func(string)) { c2PartsDemo(c, log, "122", "ChartTooltip") }
|
||||
func c2CrosshairDemo(c *ui.Context, log func(string)) { c2PartsDemo(c, log, "123", "ChartCrosshair") }
|
||||
func c2AxisDemo(c *ui.Context, log func(string)) { c2PartsDemo(c, log, "124", "ChartAxis") }
|
||||
func c2GridDemo(c *ui.Context, log func(string)) { c2PartsDemo(c, log, "125", "ChartGrid") }
|
||||
func c2AnnotationDemo(c *ui.Context, log func(string)) { c2PartsDemo(c, log, "127", "ChartAnnotation") }
|
||||
|
||||
func c2PartsDemo(c *ui.Context, log func(string), id, name string) {
|
||||
s := ui.Local(c.Root(), id, func() c2PartsState {
|
||||
return c2PartsState{status: "Ready", kind: "Linear", crosshair: true, minor: 2, draggable: true, notes: []mujica.ChartNote{
|
||||
{Kind: mujica.NoteHLine, Value: 50, Text: "Goal"},
|
||||
{Kind: mujica.NoteVLine, Value: 3, Text: "Advent"},
|
||||
{Kind: mujica.NoteBand, Value: 6, End: 8, Text: "Lent"},
|
||||
{Kind: mujica.NoteText, Value: 1, End: 90, Text: "Feast"},
|
||||
}}
|
||||
})
|
||||
c1Section(c, name, func() {
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
switch id {
|
||||
case "123":
|
||||
c2Check(c, &s.crosshair, "Crosshair", log)
|
||||
case "124":
|
||||
c2Select(c, &s.kind, "Axis kind", []string{"Linear", "Log", "Time", "Category"}, log)
|
||||
c2Check(c, &s.fixed, "Fixed ticks", log)
|
||||
case "125":
|
||||
c2Number(c, &s.minor, "Minor", 0, 8, 1, log)
|
||||
case "127":
|
||||
c2Check(c, &s.draggable, "Draggable", log)
|
||||
}
|
||||
}, func() {
|
||||
f, show := c2Frame(c, name, &s.status, log)
|
||||
ui.Column(c).Disabled(s.disabled).Children(func() {
|
||||
c1Log(log, name, c2PartsChart(c, f, show, s, id, log))
|
||||
})
|
||||
if id == "127" {
|
||||
ui.Text(c, fmt.Sprintf("Goal %.1f · Advent %.1f · Lent %.1f–%.1f · Feast %.1f, %.1f", s.notes[0].Value, s.notes[1].Value, s.notes[2].Value, s.notes[2].End, s.notes[3].Value, s.notes[3].End))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func c2PartsChart(c *ui.Context, frame mujica.ChartFrame, show bool, s *c2PartsState, id string, log func(string)) mujica.ChartResult {
|
||||
xo := mujica.ChartAxisOptions{Max: 10}
|
||||
yo := mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100}
|
||||
pts := [][2]float64{{2, 30}, {5, 80}, {8, 55}}
|
||||
if id == "124" {
|
||||
switch s.kind {
|
||||
case "Log":
|
||||
yo.Kind, yo.Min = mujica.AxisLog, 1
|
||||
case "Time":
|
||||
xo.Kind = mujica.AxisTime
|
||||
xo.From = time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
xo.To = xo.From.Add(10 * time.Hour)
|
||||
for i := range pts {
|
||||
pts[i][0] = float64(xo.From.Unix()) + pts[i][0]*3600
|
||||
}
|
||||
case "Category":
|
||||
xo.Kind, xo.Categories = mujica.AxisCategory, []string{"Nave", "Choir", "Crypt"}
|
||||
for i := range pts {
|
||||
pts[i][0] = float64(i)
|
||||
}
|
||||
}
|
||||
if s.fixed {
|
||||
yo.Ticks = []float64{10, 50, 90}
|
||||
}
|
||||
}
|
||||
x, y := mujica.ChartAxis(c, xo), mujica.ChartAxis(c, yo)
|
||||
grid := mujica.ChartGrid(c, mujica.ChartGridOptions{Y: &y, Minor: int(s.minor)})
|
||||
hair := mujica.ChartCrosshair(c, mujica.ChartCrosshairOptions{X: &x, Y: &y})
|
||||
var notes []mujica.ChartNote
|
||||
if id == "127" {
|
||||
notes = s.notes
|
||||
for i := range notes {
|
||||
notes[i].Draggable = s.draggable
|
||||
}
|
||||
}
|
||||
annotation := mujica.ChartAnnotation(c, notes, mujica.ChartAnnotationOptions{X: &x, Y: &y})
|
||||
plotOf := func(r ui.Rect) ui.Rect {
|
||||
return ui.Rect{X: r.X + 64, Y: r.Y + 16, W: max(1, r.W-100), H: max(1, r.H-56)}
|
||||
}
|
||||
at := func(r ui.Rect, i int) (float32, float32) {
|
||||
plot := plotOf(r)
|
||||
return x.Map(plot, pts[i][0]), y.Map(plot, pts[i][1])
|
||||
}
|
||||
marks := len(pts)
|
||||
if !show {
|
||||
marks = 0
|
||||
}
|
||||
colors := internal.Palette(mujica.Tokens(c))
|
||||
return mujica.Chart(c, frame, mujica.ChartSpec{
|
||||
Marks: marks,
|
||||
Pick: func(r ui.Rect, px, py float32) int {
|
||||
return hair.Snap(len(pts), func(i int) (float32, float32) { return at(r, i) }, px, py)
|
||||
},
|
||||
Anchor: at,
|
||||
Readout: func(i int) string { return fmt.Sprintf("x %s · y %s", x.Label(pts[i][0]), y.Label(pts[i][1])) },
|
||||
Tooltip: func(i int) []mujica.ChartTooltipLine {
|
||||
return []mujica.ChartTooltipLine{{Text: x.Label(pts[i][0])}, {Text: "Pews " + y.Label(pts[i][1]), Color: colors[0]}, {Text: "Candles " + y.Label(pts[i][1]/2), Color: colors[1], Shape: 1}}
|
||||
},
|
||||
Input: func(el *ui.Element, r ui.Rect) {
|
||||
if annotation.Drag(el, plotOf(r)) {
|
||||
log("ChartAnnotation: moved")
|
||||
}
|
||||
},
|
||||
Draw: func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot := plotOf(r)
|
||||
grid.Draw(p, plot)
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
annotation.Draw(p, plot)
|
||||
for i := range pts {
|
||||
px, py := at(r, i)
|
||||
internal.Marker(p, i, px, py, 10, colors[i])
|
||||
}
|
||||
if id == "123" && s.crosshair && active >= 0 {
|
||||
px, py := at(r, active)
|
||||
hair.Draw(p, plot, px, py)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
func c2LegendDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
hidden map[string]bool
|
||||
vertical, disabled bool
|
||||
max float64
|
||||
}
|
||||
s := ui.Local(c.Root(), "121", func() state { return state{hidden: map[string]bool{}, max: 3} })
|
||||
c1Section(c, "ChartLegend", func() {
|
||||
c2Check(c, &s.vertical, "Vertical", log)
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
c2Number(c, &s.max, "Max items", 0, 6, 1, log)
|
||||
}, func() {
|
||||
pal := internal.Palette(mujica.Tokens(c))
|
||||
names := []string{"Matins", "Lauds", "Prime", "Terce", "Sext", "None"}
|
||||
items := make([]mujica.ChartLegendItem, len(names))
|
||||
for i, n := range names {
|
||||
items[i] = mujica.ChartLegendItem{Name: n, Color: pal[i], Shape: i % 5}
|
||||
}
|
||||
ui.Column(c).Disabled(s.disabled).Children(func() {
|
||||
r := mujica.ChartLegend(c, &s.hidden, items, mujica.ChartLegendOptions{Vertical: s.vertical, MaxItems: int(s.max)})
|
||||
if expanded, ok := r.Expanded(); ok {
|
||||
log(fmt.Sprintf("ChartLegend: expanded %v", expanded))
|
||||
}
|
||||
if n, ok := r.Toggled(); ok {
|
||||
log("ChartLegend: toggled " + n)
|
||||
}
|
||||
})
|
||||
ui.Text(c, fmt.Sprintf("Hidden: %v", s.hidden))
|
||||
})
|
||||
}
|
||||
|
||||
func c2BrushDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
value [2]float64
|
||||
disabled bool
|
||||
step float64
|
||||
}
|
||||
s := ui.Local(c.Root(), "126", func() state { return state{value: [2]float64{20, 60}, step: 1} })
|
||||
c1Section(c, "ChartBrush", func() {
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
c2Number(c, &s.step, "Step", 1, 10, 1, log)
|
||||
}, func() {
|
||||
vals := []float64{1, 4, 2, 7, 3, 8, 4, 9}
|
||||
r := mujica.ChartBrush(c, &s.value, mujica.ChartBrushOptions{Label: "Months", Max: 100, Step: s.step, Values: vals, Disabled: s.disabled})
|
||||
if r.Changed() {
|
||||
log(fmt.Sprintf("ChartBrush: range %g–%g", s.value[0], s.value[1]))
|
||||
}
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Min: s.value[0], Max: s.value[1]})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 10})
|
||||
xs := []float64{0, 15, 30, 45, 60, 75, 90, 100}
|
||||
var visible []int
|
||||
for i, v := range xs {
|
||||
if v >= s.value[0] && v <= s.value[1] {
|
||||
visible = append(visible, i)
|
||||
}
|
||||
}
|
||||
plotOf := func(r ui.Rect) ui.Rect { return ui.Rect{X: r.X + 40, Y: r.Y + 8, W: max(1, r.W-60), H: max(1, r.H-40)} }
|
||||
at := func(r ui.Rect, i int) (float32, float32) {
|
||||
plot := plotOf(r)
|
||||
return x.Map(plot, xs[visible[i]]), y.Map(plot, vals[visible[i]])
|
||||
}
|
||||
c1Log(log, "Zoom preview", mujica.Chart(c, mujica.ChartFrame{Label: "Zoom preview", Height: 140}, mujica.ChartSpec{
|
||||
Marks: len(visible), Anchor: at,
|
||||
Pick: func(r ui.Rect, px, py float32) int {
|
||||
return internal.Nearest(len(visible), func(i int) (float32, float32) { return at(r, i) }, px, py, 24, false)
|
||||
},
|
||||
Readout: func(i int) string { return fmt.Sprintf("x %g · y %g", xs[visible[i]], vals[visible[i]]) },
|
||||
Draw: func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot := plotOf(r)
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
for i := range visible {
|
||||
px, py := at(r, i)
|
||||
internal.Marker(p, 0, px, py, 8, mujica.Tokens(c).AccentText)
|
||||
}
|
||||
},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
func c2ExportDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
width, height, scale float64
|
||||
disabled bool
|
||||
bitmap *ui.Bitmap
|
||||
bytes int
|
||||
error string
|
||||
}
|
||||
s := ui.Local(c.Root(), "128", func() state { return state{width: 400, height: 240, scale: 2} })
|
||||
c1Section(c, "ChartExport", func() {
|
||||
c2Number(c, &s.width, "Width", 200, 600, 50, log)
|
||||
c2Number(c, &s.height, "Height", 160, 400, 40, log)
|
||||
c2Number(c, &s.scale, "Scale", 1, 3, 0.5, log)
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
}, func() {
|
||||
if mujica.Button(c, "Export PNG", mujica.ButtonOptions{Disabled: s.disabled}).Clicked() {
|
||||
data, err := mujica.ChartExport(c, func(c *ui.Context) {
|
||||
mujica.BarChart(c, []string{"Nave", "Choir", "Crypt"}, []mujica.ChartSeries{{Name: "Pews", Values: []float64{3, 5, 2}}}, mujica.BarChartOptions{Frame: mujica.ChartFrame{Label: "Exported chart", Height: float32(s.height) - 80}})
|
||||
}, mujica.ChartExportOptions{Width: int(s.width), Height: int(s.height), Scale: float32(s.scale)})
|
||||
if err == nil {
|
||||
s.bitmap, err = ui.DecodeBitmap(data)
|
||||
}
|
||||
if err != nil {
|
||||
s.error = err.Error()
|
||||
log("ChartExport: " + s.error)
|
||||
} else {
|
||||
s.error = ""
|
||||
s.bytes = len(data)
|
||||
log(fmt.Sprintf("ChartExport: %d bytes", s.bytes))
|
||||
}
|
||||
}
|
||||
if s.error != "" {
|
||||
ui.Text(c, s.error).TextColor(mujica.Tokens(c).Danger).Role(ui.RoleStatus)
|
||||
}
|
||||
if s.bitmap != nil {
|
||||
ui.Image(c, s.bitmap).Size(float32(s.width), float32(s.height))
|
||||
ui.Text(c, fmt.Sprintf("PNG: %d bytes", s.bytes))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func c2SparkBarDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
status string
|
||||
width float64
|
||||
disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "132", func() state { return state{status: "Ready", width: 10} })
|
||||
c1Section(c, "SparkBar", func() {
|
||||
c2Number(c, &s.width, "Bar width", 2, 20, 1, log)
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
}, func() {
|
||||
f, show := c2Frame(c, "Alms", &s.status, log)
|
||||
vals := []float64{3, -2, 4, 1, -3}
|
||||
if !show {
|
||||
vals = nil
|
||||
}
|
||||
ui.Column(c).Disabled(s.disabled).Children(func() {
|
||||
c1Log(log, "SparkBar", mujica.SparkBar(c, vals, mujica.SparkBarOptions{Frame: f, BarWidth: float32(s.width)}))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func c2TrendDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
change, precision float64
|
||||
invert bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "133", func() state { return state{change: 12.5, precision: 1} })
|
||||
c1Section(c, "TrendIndicator", func() {
|
||||
c2Number(c, &s.change, "Change", -100, 100, 0.5, log)
|
||||
c2Number(c, &s.precision, "Precision", 0, 3, 1, log)
|
||||
c2Check(c, &s.invert, "Invert", log)
|
||||
}, func() {
|
||||
mujica.TrendIndicator(c, s.change, mujica.TrendIndicatorOptions{Precision: int(s.precision), Unit: "%", Note: "vs Lent", Invert: s.invert})
|
||||
ui.Row(c).Gap(24).Children(func() {
|
||||
mujica.TrendIndicator(c, 3, mujica.TrendIndicatorOptions{})
|
||||
mujica.TrendIndicator(c, -3, mujica.TrendIndicatorOptions{})
|
||||
mujica.TrendIndicator(c, 0, mujica.TrendIndicatorOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func c2EmptyDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
status string
|
||||
height float64
|
||||
disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "140", func() state { return state{status: "Failed", height: 180} })
|
||||
c1Section(c, "ChartEmptyState", func() {
|
||||
c2Number(c, &s.height, "Height", 160, 320, 40, log)
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
}, func() {
|
||||
f, _ := c2Frame(c, "ChartEmptyState", &s.status, log)
|
||||
ui.Column(c).Disabled(s.disabled).Border(1, mujica.Tokens(c).Border).Children(func() {
|
||||
if mujica.ChartEmptyState(c, f.Status, mujica.ChartEmptyStateOptions{Height: float32(s.height), Error: f.Error}).Changed() {
|
||||
log("ChartEmptyState: retry")
|
||||
s.status = "Loading"
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func c2Demo(t *testing.T, id string) (*ui.Tester, *[]string) {
|
||||
t.Helper()
|
||||
i := slices.IndexFunc(charts2Pages, func(p page) bool { return p.ID == id })
|
||||
if i < 0 {
|
||||
t.Fatalf("missing page %s", id)
|
||||
}
|
||||
events := &[]string{}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Scroll(c).Fill().Padding(24).Children(func() { charts2Pages[i].Demo(c, func(s string) { *events = append(*events, s) }) })
|
||||
}, 900, 760)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
return tt, events
|
||||
}
|
||||
|
||||
func TestCharts2ExistingSet(t *testing.T) {
|
||||
want := []string{"121", "122", "123", "124", "125", "126", "127", "128", "129", "130", "131", "132", "133", "138", "140"}
|
||||
var ids []string
|
||||
for _, p := range charts2Pages {
|
||||
ids = append(ids, p.ID)
|
||||
if p.Category != "Charts II" || p.Code == "" || p.Keys == "" {
|
||||
t.Fatalf("incomplete page %s", p.ID)
|
||||
}
|
||||
c2Demo(t, p.ID)
|
||||
}
|
||||
if !slices.Equal(ids, want) {
|
||||
t.Fatalf("page IDs %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharts2ReadoutsAndControlsLog(t *testing.T) {
|
||||
for _, id := range []string{"122", "123", "124", "125", "127", "129", "130", "131", "132", "138"} {
|
||||
tt, events := c2Demo(t, id)
|
||||
p := charts2Pages[slices.IndexFunc(charts2Pages, func(p page) bool { return p.ID == id })]
|
||||
label := p.Name
|
||||
if id == "132" {
|
||||
label = "Alms"
|
||||
}
|
||||
tabUntil(t, tt, label)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !slices.Contains(*events, p.Name+": point 0") {
|
||||
t.Errorf("%s readout events %q", id, *events)
|
||||
}
|
||||
if err := tt.Click("Disabled"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(*events, "Disabled = true") {
|
||||
t.Errorf("%s disabled events %q", id, *events)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharts2LegendBrushAnnotationAndRetry(t *testing.T) {
|
||||
tt, events := c2Demo(t, "121")
|
||||
if err := tt.Click("+3 more"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tt.Click("Matins"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(*events, "ChartLegend: expanded true") || !slices.Contains(*events, "ChartLegend: toggled Matins") {
|
||||
t.Fatalf("legend events %q", *events)
|
||||
}
|
||||
tt, events = c2Demo(t, "126")
|
||||
tabUntil(t, tt, "Months: 20 to 60")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !slices.Contains(*events, "ChartBrush: range 21–61") {
|
||||
t.Fatalf("brush events %q", *events)
|
||||
}
|
||||
tt, events = c2Demo(t, "127")
|
||||
tabUntil(t, tt, "ChartAnnotation")
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
if !slices.Contains(*events, "ChartAnnotation: moved") || !tt.HasText("Goal 51.0 · Advent 3.0 · Lent 6.0–8.0 · Feast 1.0, 90.0") {
|
||||
t.Fatalf("annotation events %q text %q", *events, tt.Texts())
|
||||
}
|
||||
tt, events = c2Demo(t, "140")
|
||||
if err := tt.Click("Retry"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(*events, "ChartEmptyState: retry") || !tt.HasText("Loading") {
|
||||
t.Fatalf("retry events %q", *events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharts2ExportAndTrendControls(t *testing.T) {
|
||||
tt, events := c2Demo(t, "128")
|
||||
tabUntil(t, tt, "Export PNG")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(*events) == 0 || !strings.HasPrefix((*events)[len(*events)-1], "ChartExport: ") || !strings.HasSuffix((*events)[len(*events)-1], " bytes") {
|
||||
t.Fatalf("export events %q", *events)
|
||||
}
|
||||
tt, events = c2Demo(t, "133")
|
||||
if err := tt.Click("Invert"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(*events, "Invert = true") {
|
||||
t.Fatalf("trend events %q", *events)
|
||||
}
|
||||
}
|
||||
@@ -159,10 +159,13 @@ func Marker(p *ui.Painter, i int, cx, cy, size float32, col ui.Color) {
|
||||
}
|
||||
|
||||
// Arc appends a polyline from a0 to a1, clockwise radians.
|
||||
func Arc(path *ui.Path, cx, cy, r float32, a0, a1 float64, move bool) {
|
||||
func Arc(path *ui.Path, cx, cy, r float32, a0, a1 float64, move bool, painters ...*ui.Painter) {
|
||||
n := max(2, int(math.Ceil(math.Abs(a1-a0)/(math.Pi/48))))
|
||||
for i := 0; i <= n; i++ {
|
||||
x, y := Polar(cx, cy, r, a0+(a1-a0)*float64(i)/float64(n))
|
||||
if len(painters) > 0 {
|
||||
x, y = Snap(painters[0], x), Snap(painters[0], y)
|
||||
}
|
||||
if i == 0 && move {
|
||||
path.MoveTo(x, y)
|
||||
} else {
|
||||
@@ -177,11 +180,11 @@ func Polar(cx, cy, r float32, a float64) (float32, float32) {
|
||||
}
|
||||
|
||||
// Sector returns a ring slice; zero r0 makes a wedge.
|
||||
func Sector(cx, cy, r0, r1 float32, a0, a1 float64) *ui.Path {
|
||||
func Sector(cx, cy, r0, r1 float32, a0, a1 float64, painters ...*ui.Painter) *ui.Path {
|
||||
var path ui.Path
|
||||
Arc(&path, cx, cy, r1, a0, a1, true)
|
||||
Arc(&path, cx, cy, r1, a0, a1, true, painters...)
|
||||
if r0 > 0 {
|
||||
Arc(&path, cx, cy, r0, a1, a0, false)
|
||||
Arc(&path, cx, cy, r0, a1, a0, false, painters...)
|
||||
} else {
|
||||
path.LineTo(cx, cy)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,23 @@ package mujica
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"violin_plot.readout": {"%s · median %s · quartiles %s–%s · %d values", "%s · 中位数 %s · 四分位 %s–%s · %d 个值"},
|
||||
"parallel_coordinates.filtered": {"filtered out", "已筛除"},
|
||||
"parallel_coordinates.brush": {"%s filter %s–%s", "%s 筛选 %s–%s"},
|
||||
"chord_diagram.out": {"out", "流出"},
|
||||
"chord_diagram.in": {"in", "流入"},
|
||||
"chart_annotation.readout": {"Annotation %d · %s", "标注 %d · %s"},
|
||||
"chart_annotation.x": {"x %s", "x %s"},
|
||||
"chart_annotation.y": {"y %s", "y %s"},
|
||||
"spark_bar.empty": {"No data", "暂无数据"},
|
||||
"chart_empty_state.empty": {"No data", "暂无数据"},
|
||||
"chart_legend.more": {"+%d more", "另有 %d 项"},
|
||||
"chart_legend.fewer": {"Show fewer", "收起"},
|
||||
"chart_brush.window": {"%s: %s to %s", "%s:%s 至 %s"},
|
||||
"chart_brush.start": {"Range start %s", "区间起点 %s"},
|
||||
"chart_brush.end": {"Range end %s", "区间终点 %s"},
|
||||
"violin_plot.readout": {"%s · median %s · quartiles %s–%s · %d values", "%s · 中位数 %s · 四分位 %s–%s · %d 个值"},
|
||||
"parallel_coordinates.filtered": {"filtered out", "已筛除"},
|
||||
"chord_diagram.out": {"out", "流出"},
|
||||
"chord_diagram.in": {"in", "流入"},
|
||||
"trend_indicator.up": {"up", "上升"},
|
||||
"trend_indicator.down": {"down", "下降"},
|
||||
"trend_indicator.flat": {"unchanged", "持平"},
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"math"
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
// ParallelCoordinatesOptions configure a ParallelCoordinates chart.
|
||||
type ParallelCoordinatesOptions struct {
|
||||
Frame ChartFrame
|
||||
// Names labels each row in the readout; nil uses the row number.
|
||||
// Names labels rows; nil uses row numbers.
|
||||
Names []string
|
||||
Format func(v float64) string
|
||||
}
|
||||
@@ -31,9 +32,16 @@ func ParallelCoordinates(c *ui.Context, filters *map[string][2]float64, axes []s
|
||||
if filters == nil || len(axes) < 2 || opts.Names != nil && len(opts.Names) != len(rows) {
|
||||
panic("mujica: ParallelCoordinates needs filters, two or more axes and a name per row")
|
||||
}
|
||||
for _, r := range rows {
|
||||
if len(r) != len(axes) {
|
||||
panic("mujica: ParallelCoordinates rows need one value per axis")
|
||||
seen := map[string]bool{}
|
||||
for _, name := range axes {
|
||||
if name == "" || seen[name] {
|
||||
panic("mujica: ParallelCoordinates axes need unique names")
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
for name, f := range *filters {
|
||||
if !seen[name] || math.IsNaN(f[0]) || math.IsNaN(f[1]) || math.IsInf(f[0], 0) || math.IsInf(f[1], 0) || f[0] > f[1] {
|
||||
panic("mujica: ParallelCoordinates filters need known axes and finite ascending ranges")
|
||||
}
|
||||
}
|
||||
ch := newChart(c, opts.Frame)
|
||||
@@ -41,7 +49,17 @@ func ParallelCoordinates(c *ui.Context, filters *map[string][2]float64, axes []s
|
||||
size := FontSize(c, theme.CaptionSize)
|
||||
ink := internal.Palette(k)[0]
|
||||
type scales struct{ lo, hi []float64 }
|
||||
sc := chartMemo(ch, "scales", chartID(rows), func() scales {
|
||||
sc := chartMemo(ch, "scales", [2]string{chartID(rows), chartID(axes)}, func() scales {
|
||||
for _, r := range rows {
|
||||
if len(r) != len(axes) {
|
||||
panic("mujica: ParallelCoordinates rows need one value per axis")
|
||||
}
|
||||
for _, v := range r {
|
||||
if math.IsInf(v, 0) {
|
||||
panic("mujica: ParallelCoordinates values must be finite or NaN")
|
||||
}
|
||||
}
|
||||
}
|
||||
s := scales{lo: make([]float64, len(axes)), hi: make([]float64, len(axes))}
|
||||
for a := range axes {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
@@ -65,6 +83,14 @@ func ParallelCoordinates(c *ui.Context, filters *map[string][2]float64, axes []s
|
||||
}
|
||||
return true
|
||||
}
|
||||
passing := chartMemo(ch, "pc_filter", [2]string{chartID(rows), fmt.Sprint(*filters)}, func() []bool {
|
||||
out := make([]bool, len(rows))
|
||||
for i, row := range rows {
|
||||
out[i] = keep(row)
|
||||
}
|
||||
return out
|
||||
})
|
||||
visible := ui.Local(ch.res.Element, "mujica.pc.visible", func() []int { return nil })
|
||||
geom := func(r ui.Rect) (ui.Rect, func(a int) float32, func(a int, v float64) float32) {
|
||||
plot := ui.Rect{X: r.X + 36, Y: r.Y + 24, W: r.W - 72, H: r.H - 32}
|
||||
ax := func(a int) float32 { return plot.X + plot.W*float32(a)/float32(len(axes)-1) }
|
||||
@@ -85,7 +111,7 @@ func ParallelCoordinates(c *ui.Context, filters *map[string][2]float64, axes []s
|
||||
for a, n := range axes {
|
||||
parts = append(parts, n+" "+chartValue(c, rows[i][a], opts.Format))
|
||||
}
|
||||
if !keep(rows[i]) {
|
||||
if !passing[i] {
|
||||
parts = append(parts, msg(c, "parallel_coordinates.filtered"))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
@@ -93,7 +119,8 @@ func ParallelCoordinates(c *ui.Context, filters *map[string][2]float64, axes []s
|
||||
spec.Pick = func(r ui.Rect, x, y float32) int {
|
||||
_, ax, ys := geom(r)
|
||||
best, dist := -1, float32(8)
|
||||
for i, row := range rows {
|
||||
for _, i := range *visible {
|
||||
row := rows[i]
|
||||
for a := 0; a+1 < len(axes); a++ {
|
||||
x0, x1 := ax(a), ax(a+1)
|
||||
if x < x0 || x > x1 || math.IsNaN(row[a]) || math.IsNaN(row[a+1]) {
|
||||
@@ -108,7 +135,54 @@ func ParallelCoordinates(c *ui.Context, filters *map[string][2]float64, axes []s
|
||||
return best
|
||||
}
|
||||
drag := ui.Local(ch.res.Element, "mujica.pc.drag", func() pcDrag { return pcDrag{axis: -1} })
|
||||
keyAxis := ui.Local(ch.res.Element, "mujica.pc.axis", func() int { return 0 })
|
||||
spec.Input = func(plot *ui.Element, r ui.Rect) {
|
||||
if plot.IsDisabled() {
|
||||
*drag = pcDrag{axis: -1}
|
||||
return
|
||||
}
|
||||
if plot.Shortcut(ui.Alt, ui.KeyPageDown) {
|
||||
*keyAxis = (*keyAxis + 1) % len(axes)
|
||||
}
|
||||
if plot.Shortcut(ui.Alt, ui.KeyPageUp) {
|
||||
*keyAxis = (*keyAxis + len(axes) - 1) % len(axes)
|
||||
}
|
||||
*keyAxis = min(*keyAxis, len(axes)-1)
|
||||
a := *keyAxis
|
||||
f, ok := (*filters)[axes[a]]
|
||||
if !ok {
|
||||
f = [2]float64{sc.lo[a], sc.hi[a]}
|
||||
}
|
||||
before := f
|
||||
step := (sc.hi[a] - sc.lo[a]) / 100
|
||||
if plot.Shortcut(ui.Alt, ui.KeyUp) {
|
||||
f[0] = min(f[1], f[0]+step)
|
||||
}
|
||||
if plot.Shortcut(ui.Alt, ui.KeyDown) {
|
||||
f[0] = max(sc.lo[a], f[0]-step)
|
||||
}
|
||||
if plot.Shortcut(ui.Alt|ui.Shift, ui.KeyUp) {
|
||||
f[1] = min(sc.hi[a], f[1]+step)
|
||||
}
|
||||
if plot.Shortcut(ui.Alt|ui.Shift, ui.KeyDown) {
|
||||
f[1] = max(f[0], f[1]-step)
|
||||
}
|
||||
clear := plot.Shortcut(ui.Alt, ui.KeyDelete)
|
||||
if f != before || clear && ok {
|
||||
m := maps.Clone(*filters)
|
||||
if m == nil {
|
||||
m = map[string][2]float64{}
|
||||
}
|
||||
if clear {
|
||||
delete(m, axes[a])
|
||||
} else {
|
||||
m[axes[a]] = f
|
||||
}
|
||||
*filters, ch.res.value = m, true
|
||||
c.Invalidate()
|
||||
c.Announce(fmt.Sprintf(msg(c, "parallel_coordinates.brush"), axes[a], chartValue(c, f[0], opts.Format), chartValue(c, f[1], opts.Format)))
|
||||
slog.Debug("mujica: parallel_coordinates keyboard", "axis", axes[a])
|
||||
}
|
||||
box, ax, _ := geom(r)
|
||||
px, py, _ := plot.PointerPosition()
|
||||
_, _, held := plot.Dragged()
|
||||
@@ -136,6 +210,7 @@ func ParallelCoordinates(c *ui.Context, filters *map[string][2]float64, axes []s
|
||||
m[axes[a]] = [2]float64{min(v(drag.y0), v(drag.y1)), max(v(drag.y0), v(drag.y1))}
|
||||
}
|
||||
*filters, ch.res.value = m, true
|
||||
c.Invalidate()
|
||||
*drag = pcDrag{axis: -1}
|
||||
slog.Debug("mujica: parallel_coordinates brush", "axis", axes[a], "filters", len(m))
|
||||
}
|
||||
@@ -143,6 +218,7 @@ func ParallelCoordinates(c *ui.Context, filters *map[string][2]float64, axes []s
|
||||
shownDrag := *drag
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot, ax, ys := geom(r)
|
||||
*visible = chartVisibleIndices(len(rows), max(1, int(plot.H*2)), active)
|
||||
line := func(row []float64, w float32, col ui.Color) {
|
||||
var path ui.Path
|
||||
started := false
|
||||
@@ -151,7 +227,7 @@ func ParallelCoordinates(c *ui.Context, filters *map[string][2]float64, axes []s
|
||||
started = false
|
||||
continue
|
||||
}
|
||||
x, y := ax(a), ys(a, row[a])
|
||||
x, y := internal.Snap(p, ax(a)), internal.Snap(p, ys(a, row[a]))
|
||||
if started {
|
||||
path.LineTo(x, y)
|
||||
} else {
|
||||
@@ -161,14 +237,14 @@ func ParallelCoordinates(c *ui.Context, filters *map[string][2]float64, axes []s
|
||||
}
|
||||
p.StrokePath(&path, w, col)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if !keep(row) {
|
||||
line(row, 1, k.Border)
|
||||
for _, i := range *visible {
|
||||
if !passing[i] {
|
||||
line(rows[i], 1, k.Border)
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
if keep(row) {
|
||||
line(row, 1.25, ink.Alpha(0.6))
|
||||
for _, i := range *visible {
|
||||
if passing[i] {
|
||||
line(rows[i], 1.25, ink)
|
||||
}
|
||||
}
|
||||
if active >= 0 {
|
||||
@@ -192,5 +268,8 @@ func ParallelCoordinates(c *ui.Context, filters *map[string][2]float64, axes []s
|
||||
}
|
||||
}
|
||||
res, _ := ch.build(spec)
|
||||
if res.Element.IsDisabled() {
|
||||
*drag = pcDrag{axis: -1}
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func TestParallelCoordinatesHoverAndBad(t *testing.T) {
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
b := h.res.Element.Bounds()
|
||||
plot := ui.Rect{X: b.X + 36, Y: b.Y + 24, W: b.W - 72, H: 240 - 32}
|
||||
// A quarter into the first gap, row 2 (Age 50 of 20..80, Hymns 5 of 0..8) is 0.53 up.
|
||||
// Row 2 crosses this point between the first axes.
|
||||
tt.Move(plot.X+plot.W/8, plot.Y+plot.H*(1-0.53125))
|
||||
if h.res.Point != 2 {
|
||||
t.Fatalf("hover picked %d", h.res.Point)
|
||||
@@ -82,3 +82,51 @@ func TestParallelCoordinatesSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestParallelCoordinatesKeyboardBrushAndDisabled(t *testing.T) {
|
||||
filters := map[string][2]float64{}
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return ParallelCoordinates(c, &filters, pcAxes, pcRows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Monks"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Monks")
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
f, ok := filters["Age"]
|
||||
if !ok || math.Abs(f[0]-20.6) > 1e-9 || f[1] != 80 {
|
||||
t.Fatalf("keyboard brush %v", filters)
|
||||
}
|
||||
h.disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
if filters["Age"] != f {
|
||||
t.Fatal("disabled keyboard brush")
|
||||
}
|
||||
h.disabled = false
|
||||
tt.Frame()
|
||||
tabTo(t, tt, "Monks")
|
||||
tt.Key(ui.Alt, ui.KeyDelete)
|
||||
if len(filters) != 0 {
|
||||
t.Fatalf("clear brush %v", filters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelCoordinatesLargeDataBounded(t *testing.T) {
|
||||
rows := make([][]float64, 100_000)
|
||||
for i := range rows {
|
||||
rows[i] = []float64{float64(i % 70), float64(i % 11), float64(i % 17)}
|
||||
}
|
||||
filters := map[string][2]float64{}
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return ParallelCoordinates(c, &filters, pcAxes, rows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Large parallel"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
shown := ui.Local(h.res.Element, "mujica.pc.visible", func() []int { return nil })
|
||||
if len(*shown) == 0 || len(*shown) > 480 {
|
||||
t.Fatalf("built %d rows", len(*shown))
|
||||
}
|
||||
tabTo(t, tt, "Large parallel")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if h.res.Point != 99_999 || len(*shown) > 481 {
|
||||
t.Fatalf("last row %d, built %d", h.res.Point, len(*shown))
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func TestPieChartBad(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPieChartSnapshot(t *testing.T) {
|
||||
snapshot(t, "pie_chart", 640, 320, func(c *ui.Context) {
|
||||
snapshot(t, "pie_chart", 640, 360, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(16).Children(func() {
|
||||
ui.Column(c).Width(296).Children(func() {
|
||||
PieChart(c, pieSlices, PieChartOptions{Frame: ChartFrame{Label: "Pie", Height: 220}})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
"math"
|
||||
)
|
||||
|
||||
// SparkBarOptions configure a SparkBar; Frame.Height 0 means 28 DIPs.
|
||||
type SparkBarOptions struct {
|
||||
Frame ChartFrame
|
||||
// BarWidth defaults to 6 DIPs; width fits the window.
|
||||
BarWidth float32
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// SparkBar draws signed inline bars and highlights the last.
|
||||
func SparkBar(c *ui.Context, values []float64, opts SparkBarOptions) ChartResult {
|
||||
if opts.BarWidth < 0 || math.IsNaN(float64(opts.BarWidth)) || math.IsInf(float64(opts.BarWidth), 0) {
|
||||
panic("mujica: SparkBar BarWidth must be ≥ 0")
|
||||
}
|
||||
bw := opts.BarWidth
|
||||
if bw == 0 {
|
||||
bw = 6
|
||||
}
|
||||
w, _ := c.Size()
|
||||
frame := opts.Frame
|
||||
if frame.Empty == "" {
|
||||
frame.Empty = msg(c, "spark_bar.empty")
|
||||
}
|
||||
return Sparkline(c, values, SparklineOptions{Frame: frame, Kind: SparklineBar, Width: min(max(28, float32(len(values))*(bw+1)+6), max(28, w-48)), Format: opts.Format})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
var sparkBarValues = []float64{3, -2, 4, 1, -3}
|
||||
|
||||
func TestSparkBarWidthAndColors(t *testing.T) {
|
||||
values := sparkBarValues
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "Alms"}, BarWidth: 10})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 300, 200)
|
||||
r, _ := tt.Find("Alms")
|
||||
if r.W != 5*11+6 || chartReadout(tt) != "-3" {
|
||||
t.Fatalf("box %v readout %q", r, chartReadout(tt))
|
||||
}
|
||||
k := theme.Light()
|
||||
at := func(i int, below bool) color.RGBA {
|
||||
x := r.X + 3 + (r.W-6)*float32(i)/4
|
||||
y := r.Y + r.H*0.3
|
||||
if below {
|
||||
y = r.Y + r.H*0.65
|
||||
}
|
||||
return color.RGBAModel.Convert(tt.Image().At(int(x), int(y))).(color.RGBA)
|
||||
}
|
||||
if at(4, true) != toRGBA(k.Danger) || at(0, false) != toRGBA(k.TextMuted) {
|
||||
t.Fatalf("last fall %v, first rise %v", at(4, true), at(0, false))
|
||||
}
|
||||
tabTo(t, tt, "Alms")
|
||||
tt.Key(0, ui.KeyHome)
|
||||
if chartReadout(tt) != "3" || at(0, false) != toRGBA(internal.Palette(k)[0]) {
|
||||
t.Fatalf("read bar not highlighted: %q", chartReadout(tt))
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for a negative width")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "x"}, BarWidth: -1})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestSparkBarSnapshot(t *testing.T) {
|
||||
snapshot(t, "spark_bar", 360, 80, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(24).Children(func() {
|
||||
SparkBar(c, sparkBarValues, SparkBarOptions{Frame: ChartFrame{Label: "Alms"}})
|
||||
SparkBar(c, []float64{1, 3, 2, 5, 4, 6, 8}, SparkBarOptions{Frame: ChartFrame{Label: "Bells"}, BarWidth: 8})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestSparkBarShrinkingAndDisabled(t *testing.T) {
|
||||
values := []float64{1, 2, 3, 4}
|
||||
disabled := false
|
||||
var r ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
r = SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "Bars"}})
|
||||
})
|
||||
}, 320, 100)
|
||||
tabTo(t, tt, "Bars")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
values = []float64{7}
|
||||
tt.Frame()
|
||||
if r.Point != 0 || !tt.HasText("7") {
|
||||
t.Fatalf("shortened data: point %d, text %q", r.Point, tt.Texts())
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if r.Point != -1 {
|
||||
t.Fatalf("disabled point %d", r.Point)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSparkBarLargeData(t *testing.T) {
|
||||
values := make([]float64, 100_000)
|
||||
for i := range values {
|
||||
values[i] = float64(i%17 - 8)
|
||||
}
|
||||
var built int
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
res := SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "Large bars"}})
|
||||
memo := ui.Local(res.Element, "mujica.sparkline.data", func() sparkMemo { return sparkMemo{} })
|
||||
built = len(memo.indices)
|
||||
}, 320, 100)
|
||||
tt.Frame()
|
||||
r, _ := tt.Find("Large bars")
|
||||
if built == 0 || built > 4*int(r.W)+4 {
|
||||
t.Fatalf("built %d bars for width %v", built, r.W)
|
||||
}
|
||||
if r.W > 320 {
|
||||
t.Fatalf("unbounded plot width %v", r.W)
|
||||
}
|
||||
tabTo(t, tt, "Large bars")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if !tt.HasText("-3") {
|
||||
t.Fatalf("last value %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSparkBarFiniteWidthAndStates(t *testing.T) {
|
||||
charts2MustPanic(t, func(c *ui.Context) {
|
||||
SparkBar(c, []float64{1}, SparkBarOptions{Frame: ChartFrame{Label: "Bars"}, BarWidth: float32(math.Inf(1))})
|
||||
})
|
||||
status := DataReady
|
||||
retries := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
r := SparkBar(c, []float64{2, -1, 3}, SparkBarOptions{Frame: ChartFrame{Label: "Bars", Status: status, Height: 40}})
|
||||
if r.Retried() {
|
||||
retries++
|
||||
}
|
||||
}, 320, 100)
|
||||
ready, _ := tt.Find("Bars")
|
||||
status = DataFailed
|
||||
tt.Frame()
|
||||
if err := tt.Click("Retry"); err != nil || retries != 1 {
|
||||
t.Fatalf("retry %v count %d", err, retries)
|
||||
}
|
||||
status = DataLoading
|
||||
tt.Frame()
|
||||
if ready.W != 28 || ready.H != 40 {
|
||||
t.Fatalf("ready plot %v", ready)
|
||||
}
|
||||
status = DataReady
|
||||
tt.Frame()
|
||||
tt.Move(319, 99)
|
||||
tabTo(t, tt, "Bars")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if !tt.HasText("3") {
|
||||
t.Fatalf("restored data %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSparkBarMinimumHitWidth(t *testing.T) {
|
||||
for _, values := range [][]float64{nil, {1}} {
|
||||
var result ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
result = SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "Bars"}})
|
||||
}, 200, 100)
|
||||
tt.Frame()
|
||||
if result.Element.Bounds().W < 28 {
|
||||
t.Fatalf("hit width %v", result.Element.Bounds().W)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ type SparklineOptions struct {
|
||||
// Sparkline is an inline chart without axes.
|
||||
func Sparkline(c *ui.Context, values []float64, opts SparklineOptions) ChartResult {
|
||||
f := opts.Frame
|
||||
if f.Label == "" || f.Height < 0 || opts.Width < 0 || opts.Kind > SparklineArea {
|
||||
if f.Label == "" || f.Height < 0 || opts.Width < 0 || opts.Kind < SparklineLine || opts.Kind > SparklineArea {
|
||||
panic("mujica: Sparkline needs a Label, sizes ≥ 0 and a known Kind")
|
||||
}
|
||||
if f.Height == 0 {
|
||||
@@ -46,16 +46,23 @@ func Sparkline(c *ui.Context, values []float64, opts SparklineOptions) ChartResu
|
||||
u := Density(c).Unit()
|
||||
col := internal.Palette(k)[0]
|
||||
size := FontSize(c, theme.CaptionSize)
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
last := -1
|
||||
for i, v := range values {
|
||||
if !math.IsNaN(v) {
|
||||
lo, hi, last = min(lo, v), max(hi, v), i
|
||||
res := ChartResult{Point: -1}
|
||||
res.Element = ui.Row(c).Gap(u * 2).AlignItems(ui.Center).MinWidth(0)
|
||||
memo := ui.Local(res.Element, "mujica.sparkline.data", func() sparkMemo { return sparkMemo{} })
|
||||
if id := chartID(values); memo.id != id {
|
||||
*memo = sparkMemo{id: id, lo: math.Inf(1), hi: math.Inf(-1), last: -1}
|
||||
for i, v := range values {
|
||||
if math.IsInf(v, 0) {
|
||||
panic("mujica: Sparkline values must be finite or NaN")
|
||||
}
|
||||
if !math.IsNaN(v) {
|
||||
memo.lo, memo.hi, memo.last = min(memo.lo, v), max(memo.hi, v), i
|
||||
}
|
||||
}
|
||||
}
|
||||
res := ChartResult{Point: -1}
|
||||
res.Element = ui.Row(c).Gap(u * 2).AlignItems(ui.Center)
|
||||
lo, hi, last := memo.lo, memo.hi, memo.last
|
||||
st := ui.Local(res.Element, "mujica.sparkline", func() chartState { return chartState{focus: -1, last: -1} })
|
||||
st.focus = min(st.focus, len(values)-1)
|
||||
res.Element.Children(func() {
|
||||
if f.Status == DataLoading || f.Status == DataFailed || last < 0 {
|
||||
box := ui.Row(c).Size(w, f.Height).AlignItems(ui.Center).Gap(u)
|
||||
@@ -115,22 +122,18 @@ func Sparkline(c *ui.Context, values []float64, opts SparklineOptions) ChartResu
|
||||
active := -1
|
||||
if px, _, over := plot.PointerPosition(); over && !plot.IsDisabled() {
|
||||
active = max(0, min(n-1, int(math.Round(float64((px-3)/(w-6)*float32(max(n-1, 1)))))))
|
||||
} else if plot.Focused() {
|
||||
} else if plot.Focused() && !plot.IsDisabled() {
|
||||
active = st.focus
|
||||
}
|
||||
res.Point = active
|
||||
plot.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
s := ys(r)
|
||||
if opts.Kind == SparklineBar {
|
||||
bw := max((r.W-6)/float32(n)-1, 1)
|
||||
for i, v := range values {
|
||||
if math.IsNaN(v) {
|
||||
continue
|
||||
}
|
||||
a, b := s.Map(0), s.Map(v)
|
||||
cx := x(r, i)
|
||||
p.Fill(ui.Rect{X: cx - bw/2, Y: min(a, b), W: bw, H: max(abs32(a-b), 1)}, col, 0)
|
||||
if memo.width != r.W || memo.indices == nil {
|
||||
memo.width = r.W
|
||||
memo.indices = internal.Columns(n, func(i int) float32 { return x(r, i) }, func(i int) float64 { return values[i] })
|
||||
}
|
||||
sparkBars(p, r, values, memo.indices, func(i int) float32 { return x(r, i) }, s, last, active, k)
|
||||
} else {
|
||||
chartRuns(values, func(i int) float32 { return x(r, i) }, func(idx []int) {
|
||||
var line ui.Path
|
||||
@@ -168,3 +171,34 @@ func Sparkline(c *ui.Context, values []float64, opts SparklineOptions) ChartResu
|
||||
st.last = res.Point
|
||||
return res
|
||||
}
|
||||
|
||||
type sparkMemo struct {
|
||||
id string
|
||||
lo, hi float64
|
||||
last int
|
||||
width float32
|
||||
indices []int
|
||||
}
|
||||
|
||||
// sparkBars highlights the last and active bars.
|
||||
func sparkBars(p *ui.Painter, r ui.Rect, values []float64, indices []int, x func(int) float32, s internal.Linear, last, active int, k theme.Tokens) {
|
||||
n := len(values)
|
||||
bw := max((r.W-6)/float32(n)-1, 1)
|
||||
zero := s.Map(0)
|
||||
internal.Rule(p, r.X, r.X+r.W, zero, k.Border)
|
||||
for _, i := range indices {
|
||||
v := values[i]
|
||||
if math.IsNaN(v) {
|
||||
continue
|
||||
}
|
||||
col := k.TextMuted
|
||||
if i == last || i == active {
|
||||
col = internal.Palette(k)[0]
|
||||
if v < 0 {
|
||||
col = k.Danger
|
||||
}
|
||||
}
|
||||
b := s.Map(v)
|
||||
p.Fill(ui.Rect{X: x(i) - bw/2, Y: min(zero, b), W: bw, H: max(abs32(zero-b), 1)}, col, 0)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,70 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// TrendIndicatorOptions configure a TrendIndicator.
|
||||
type TrendIndicatorOptions struct {
|
||||
// Precision is the decimals shown, 0 or more.
|
||||
Precision int
|
||||
// Unit follows the number, as "%".
|
||||
Unit string
|
||||
// Note explains the change, as "vs last week".
|
||||
Note string
|
||||
// Invert marks falls as good, as for costs or errors.
|
||||
Invert bool
|
||||
// Format replaces the built-in signed number.
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// TrendIndicator shows signed change, direction, and semantic color.
|
||||
func TrendIndicator(c *ui.Context, change float64, opts TrendIndicatorOptions) *ui.Element {
|
||||
if opts.Precision < 0 || math.IsNaN(change) || math.IsInf(change, 0) {
|
||||
panic("mujica: TrendIndicator needs a number and Precision ≥ 0")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
word, text := trendWords(c, change, opts)
|
||||
col, icon := k.TextMuted, "minus"
|
||||
switch {
|
||||
case change > 0:
|
||||
col, icon = k.Success, "arrow-up"
|
||||
case change < 0:
|
||||
col, icon = k.Danger, "arrow-down"
|
||||
}
|
||||
if opts.Invert && change != 0 {
|
||||
col = map[bool]ui.Color{true: k.Danger, false: k.Success}[change > 0]
|
||||
}
|
||||
e := ui.Row(c).Gap(u).AlignItems(ui.Center).Wrap().Role(ui.RoleText).Label(word + " " + text)
|
||||
e.Children(func() {
|
||||
ui.Row(c).Gap(u / 2).AlignItems(ui.Center).TextColor(col).Children(func() {
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14)
|
||||
ui.Text(c, text).FontWeight(600).SingleLine()
|
||||
})
|
||||
if opts.Note != "" {
|
||||
ui.Text(c, opts.Note).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
// trendWords formats direction and signed change.
|
||||
func trendWords(c *ui.Context, change float64, opts TrendIndicatorOptions) (word, text string) {
|
||||
word = msg(c, "trend_indicator.flat")
|
||||
switch {
|
||||
case change > 0:
|
||||
word = msg(c, "trend_indicator.up")
|
||||
case change < 0:
|
||||
word = msg(c, "trend_indicator.down")
|
||||
}
|
||||
if opts.Format != nil {
|
||||
return word, opts.Format(change)
|
||||
}
|
||||
return word, statisticSigned(change, opts.Precision) + opts.Unit
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
func TestTrendIndicatorWordsAndColors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
change float64
|
||||
invert bool
|
||||
label string
|
||||
good bool
|
||||
}{
|
||||
{12.5, false, "up +12.5%", true},
|
||||
{-3, false, "down −3.0%", false},
|
||||
{-3, true, "down −3.0%", true},
|
||||
{0, false, "unchanged 0.0%", false},
|
||||
} {
|
||||
var r ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
ui.Column(c).Padding(10).AlignItems(ui.Start).Children(func() {
|
||||
r = TrendIndicator(c, tc.change, TrendIndicatorOptions{Precision: 1, Unit: "%", Invert: tc.invert, Note: "vs Lent"}).Bounds()
|
||||
})
|
||||
}, 300, 100)
|
||||
tt.Frame()
|
||||
if _, ok := tt.Find(tc.label); !ok || !tt.HasText("vs Lent") {
|
||||
t.Fatalf("%v: no %q in %q", tc.change, tc.label, tt.Texts())
|
||||
}
|
||||
k := theme.Light()
|
||||
want := map[bool]color.RGBA{true: toRGBA(k.Success), false: toRGBA(k.Danger)}[tc.good]
|
||||
if tc.change == 0 {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
img := tt.Image()
|
||||
for x := int(r.X); x < int(r.X+16) && !found; x++ {
|
||||
for y := int(r.Y); y < int(r.Y+r.H) && !found; y++ {
|
||||
found = color.RGBAModel.Convert(img.At(x, y)).(color.RGBA) == want
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%v invert=%v: arrow not in %v", tc.change, tc.invert, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrendIndicatorFormatAndBad(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
TrendIndicator(c, 2, TrendIndicatorOptions{Format: func(v float64) string { return "two more" }})
|
||||
}, 200, 100)
|
||||
if !tt.HasText("two more") {
|
||||
t.Fatalf("format %q", tt.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for NaN")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); TrendIndicator(c, math.NaN(), TrendIndicatorOptions{}) }, 100, 100)
|
||||
}
|
||||
|
||||
func TestTrendIndicatorSnapshot(t *testing.T) {
|
||||
snapshot(t, "trend_indicator", 420, 80, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(24).Children(func() {
|
||||
TrendIndicator(c, 12.5, TrendIndicatorOptions{Precision: 1, Unit: "%", Note: "vs Lent"})
|
||||
TrendIndicator(c, -3, TrendIndicatorOptions{Unit: " souls"})
|
||||
TrendIndicator(c, -4, TrendIndicatorOptions{Unit: "%", Invert: true})
|
||||
TrendIndicator(c, 0, TrendIndicatorOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestTrendIndicatorFiniteAndChinese(t *testing.T) {
|
||||
charts2MustPanic(t, func(c *ui.Context) { TrendIndicator(c, math.Inf(1), TrendIndicatorOptions{}) })
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Locale: ZhCN})
|
||||
TrendIndicator(c, -2, TrendIndicatorOptions{Unit: "%"})
|
||||
}, 200, 100)
|
||||
if _, ok := tt.Find("下降 −2%"); !ok {
|
||||
t.Fatalf("Chinese readout %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,12 @@ import (
|
||||
// ViolinPlotOptions configure a ViolinPlot.
|
||||
type ViolinPlotOptions struct {
|
||||
Frame ChartFrame
|
||||
// Bandwidth is the kernel width; 0 uses Silverman's rule per group.
|
||||
// Bandwidth defaults to the per-group estimate.
|
||||
Bandwidth float64
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// violinShape is one group's density outline over its value range, and its quartiles.
|
||||
// violinShape stores density samples and quartiles.
|
||||
type violinShape struct {
|
||||
ys, dens []float64
|
||||
stats boxStats
|
||||
@@ -27,9 +27,9 @@ type violinShape struct {
|
||||
// violinPoints is how many values each outline is sampled at.
|
||||
const violinPoints = 64
|
||||
|
||||
// ViolinPlot mirrors each group's density around its middle, with the median and quartiles marked.
|
||||
// ViolinPlot compares density outlines, medians, and quartiles.
|
||||
func ViolinPlot(c *ui.Context, groups []BoxGroup, opts ViolinPlotOptions) ChartResult {
|
||||
if opts.Bandwidth < 0 {
|
||||
if opts.Bandwidth < 0 || math.IsNaN(opts.Bandwidth) || math.IsInf(opts.Bandwidth, 0) {
|
||||
panic("mujica: ViolinPlot Bandwidth must be ≥ 0")
|
||||
}
|
||||
ch := newChart(c, opts.Frame)
|
||||
@@ -37,10 +37,12 @@ func ViolinPlot(c *ui.Context, groups []BoxGroup, opts ViolinPlotOptions) ChartR
|
||||
pal := internal.Palette(k)
|
||||
size := FontSize(c, theme.CaptionSize)
|
||||
ids := ""
|
||||
seen := map[string]bool{}
|
||||
for _, g := range groups {
|
||||
if g.Name == "" {
|
||||
if g.Name == "" || seen[g.Name] {
|
||||
panic("mujica: ViolinPlot groups need names")
|
||||
}
|
||||
seen[g.Name] = true
|
||||
ids += chartID(g.Values) + ";"
|
||||
}
|
||||
shapes := chartMemo(ch, "violins", fmt.Sprint(ids, opts.Bandwidth), func() []violinShape {
|
||||
@@ -59,6 +61,9 @@ func ViolinPlot(c *ui.Context, groups []BoxGroup, opts ViolinPlotOptions) ChartR
|
||||
for _, o := range s.outliers {
|
||||
lo, hi = min(lo, o), max(hi, o)
|
||||
}
|
||||
if lo == hi {
|
||||
lo, hi = lo-3*h, hi+3*h
|
||||
}
|
||||
ys := make([]float64, violinPoints)
|
||||
for j := range ys {
|
||||
ys[j] = lo + (hi-lo)*float64(j)/float64(violinPoints-1)
|
||||
@@ -86,6 +91,7 @@ func ViolinPlot(c *ui.Context, groups []BoxGroup, opts ViolinPlotOptions) ChartR
|
||||
return plot, internal.Band{N: len(groups), R0: plot.X, R1: plot.X + plot.W, Pad: 0.15},
|
||||
internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
|
||||
}
|
||||
visible := ui.Local(ch.res.Element, "mujica.violin.visible", func() []int { return nil })
|
||||
spec := ChartSpec{Marks: n}
|
||||
spec.Readout = func(i int) string {
|
||||
s := shapes[i].stats
|
||||
@@ -106,11 +112,13 @@ func ViolinPlot(c *ui.Context, groups []BoxGroup, opts ViolinPlotOptions) ChartR
|
||||
internal.AxisY(p, plot, ys, internal.Ticks(lo, hi, step), func(v float64) string { return internal.FormatTick(v, step) }, size, k)
|
||||
var xs []float32
|
||||
var names []string
|
||||
for i, g := range groups {
|
||||
xs, names = append(xs, band.Center(i)), append(names, g.Name)
|
||||
*visible = chartVisibleIndices(len(groups), max(1, int(plot.W)), active)
|
||||
for _, i := range *visible {
|
||||
xs, names = append(xs, band.Center(i)), append(names, groups[i].Name)
|
||||
}
|
||||
internal.AxisX(p, plot, xs, names, size, k)
|
||||
for i, s := range shapes {
|
||||
for _, i := range *visible {
|
||||
s := shapes[i]
|
||||
if s.stats.n == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -121,15 +129,16 @@ func ViolinPlot(c *ui.Context, groups []BoxGroup, opts ViolinPlotOptions) ChartR
|
||||
}
|
||||
var outline ui.Path
|
||||
for j, y := range s.ys {
|
||||
x := cx + half*float32(s.dens[j]/top)
|
||||
x := internal.Snap(p, cx+half*float32(s.dens[j]/top))
|
||||
py := internal.Snap(p, ys.Map(y))
|
||||
if j == 0 {
|
||||
outline.MoveTo(x, ys.Map(y))
|
||||
outline.MoveTo(x, py)
|
||||
} else {
|
||||
outline.LineTo(x, ys.Map(y))
|
||||
outline.LineTo(x, py)
|
||||
}
|
||||
}
|
||||
for j := len(s.ys) - 1; j >= 0; j-- {
|
||||
outline.LineTo(cx-half*float32(s.dens[j]/top), ys.Map(s.ys[j]))
|
||||
outline.LineTo(internal.Snap(p, cx-half*float32(s.dens[j]/top)), internal.Snap(p, ys.Map(s.ys[j])))
|
||||
}
|
||||
outline.Close()
|
||||
col := pal[i%len(pal)]
|
||||
@@ -139,7 +148,7 @@ func ViolinPlot(c *ui.Context, groups []BoxGroup, opts ViolinPlotOptions) ChartR
|
||||
w = 3
|
||||
}
|
||||
p.StrokePath(&outline, w, col)
|
||||
// The quartile bar and a diamond at the median read without color.
|
||||
// Quartile bars and median diamonds distinguish values.
|
||||
p.Fill(ui.Rect{X: cx - 3, Y: ys.Map(s.stats.q3), W: 6, H: max(ys.Map(s.stats.q1)-ys.Map(s.stats.q3), 1)}, k.Text, 1)
|
||||
p.FillPath(internal.Diamond(internal.Snap(p, cx), internal.Snap(p, ys.Map(s.stats.median)), 10), k.Surface)
|
||||
p.FillPath(internal.Diamond(internal.Snap(p, cx), internal.Snap(p, ys.Map(s.stats.median)), 7), k.Text)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
@@ -43,3 +44,35 @@ func TestViolinPlotSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestViolinPlotFiniteBandwidthAndFlatShape(t *testing.T) {
|
||||
charts2MustPanic(t, func(c *ui.Context) {
|
||||
ViolinPlot(c, []BoxGroup{{Name: "Flat", Values: []float64{4, 4}}}, ViolinPlotOptions{Frame: ChartFrame{Label: "Flat"}, Bandwidth: math.NaN()})
|
||||
})
|
||||
}
|
||||
|
||||
func TestViolinPlotLargeDataAndDisabled(t *testing.T) {
|
||||
values := make([]float64, 100_000)
|
||||
for i := range values {
|
||||
values[i] = float64(i % 100)
|
||||
}
|
||||
groups := []BoxGroup{{Name: "Sample", Values: values}}
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return ViolinPlot(c, groups, ViolinPlotOptions{Frame: ChartFrame{Label: "Large violin"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Large violin")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if chartReadout(tt) != "Sample · median 49.5 · quartiles 24.75–74.25 · 100000 values" {
|
||||
t.Fatalf("large readout %q", chartReadout(tt))
|
||||
}
|
||||
shown := ui.Local(h.res.Element, "mujica.violin.visible", func() []int { return nil })
|
||||
if len(*shown) != 1 {
|
||||
t.Fatalf("built %d violins", len(*shown))
|
||||
}
|
||||
h.disabled = true
|
||||
tt.Frame()
|
||||
if h.res.Point != -1 {
|
||||
t.Fatalf("disabled violin %d", h.res.Point)
|
||||
}
|
||||
}
|
||||