Compare commits
4 Commits
553c55026a
...
4d96ab0912
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d96ab0912 | |||
| 52346bdd09 | |||
| d457f8244e | |||
| 398018b51e |
447
README.md
@@ -1,168 +1,341 @@
|
|||||||
# Fenix 8 V3 — Garmin 数据表盘
|
# Fenix 8 V3 — Garmin 数据表盘
|
||||||
|
|
||||||
一款基于 Connect IQ 设计稿(`watchface-kit`)1:1 还原的多指标数据表盘。设计画布为 **500×500** 正方形,渲染时按屏幕宽度等比缩放,支持圆形 AMOLED(Fenix 8 / Enduro 3 / Epix 2)与圆屏 MIP(Fenix 7 / FR965 等)设备。
|
按 `watchface-kit` 设计稿 1:1 还原的多指标数据表盘。设计画布 **500×500**,渲染时按屏宽等比缩放 `s = screenWidth / 500`,支持圆形 AMOLED(Fenix 8 / Epix 2 / Venu 3 等)与圆屏 MIP(Fenix 7 / Enduro 3 / FR255 等)共 17 款设备。
|
||||||
|
|
||||||
- **SDK**:Connect IQ SDK 9.1.0(`monkeyc` 9.1.0,API level `minSdkVersion="4.2.0"`)
|
- **SDK**:Connect IQ SDK 9.1.0(`monkeyc` 9.1.0,`minSdkVersion="4.2.0"`)
|
||||||
- **语言**:Monkey C
|
- **语言**:Monkey C;界面文案含英文与简体中文
|
||||||
- **类型**:`watchface`
|
- **类型**:`watchface`
|
||||||
- **应用 ID**:`f8a3c1b2c3d44e5f8a9b0c1d2e3f4a5b`
|
- **应用 ID**:`f8a3c1b2c3d44e5f8a9b0c1d2e3f4a5b`
|
||||||
|
|
||||||
---
|
设计稿本体(7 张 SVG + tokens + 逆向脚本)在 `design/`,是本仓库所有坐标与配色的唯一来源。
|
||||||
|
|
||||||
## 1. 布局还原(与设计稿基准)
|
|
||||||
|
|
||||||
所有坐标以 500×500 设计画布为基准,渲染时统一乘以缩放系数 `s = screenWidth / 500.0`(Enduro 3 屏宽 416px → `s≈0.832`)。下表中 `y` 为设计稿原始像素值。
|
|
||||||
|
|
||||||
| 区域 | 设计坐标(500 基准) | 内容 | 说明 |
|
|
||||||
|------|----------------------|------|------|
|
|
||||||
| 进度环 | 外半径 `rOut=249.5`,内半径 `rIn=231.5` | 四段进度弧 + 4 个锚点圆点 | 见 §2 |
|
|
||||||
| 电量 | `y=53.5`,电池框居中偏左 | 电池图标 + 百分比 | `System.getSystemStats().battery` |
|
|
||||||
| 上方双复杂功能 | 图标 `y=90`,数值 `y=130.5`;左侧 `x=156`,右侧 `x=343` | 天气温度 / 卡路里 / 步数 / 距离 / 楼层 / 电量 | 见 §4 |
|
|
||||||
| 中央时间 | `y=220`;小时 `x=211`(右对齐),分钟 `x=272`(左对齐) | `HH : MM`(12 小时制) | 冒号用两块矩形块,位于 `x=238` |
|
|
||||||
| 日期带 | 矩形 `y=286.5`,高 `44` | 蓝牙图标 + 星期 + 月日 + AM/PM | 蓝牙图标 `x=36.5`,星期 `x=177.5`,月日 `x=297.5` |
|
|
||||||
| 底部指标 | 数值 `y=366`,标签 `y=410.5` | 左侧距离(`DST`),右侧步数(`STEP`) | 距离单位 km |
|
|
||||||
|
|
||||||
进度环几何(取自设计稿 `rebuild.py`):
|
|
||||||
|
|
||||||
- **顶弧**:22 根刻度,角度 `TOP = [2,6,…,86]`(步距 4),线宽 `13`,渐变停止点 9 个(`8→86`)。
|
|
||||||
- **底弧**:21 根刻度,角度 `BOT = [6,9,…,66]`(步距 3),线宽 `9`,渐变停止点 7 个(`7→47`)。
|
|
||||||
- **锚点圆点**:位于角度 `[0.0, 93.3, 180.0, 266.7]`,圆心半径 `240.5`,圆点半径 `9.5`。
|
|
||||||
- 刻度“点亮”数量按对应指标进度换算:`litTR/TL = progress × 22`,`litBR/BL = progress × 21`。
|
|
||||||
|
|
||||||
时间字体为 `FONT_NUMBER_HOT`(大号数字),其余为 `FONT_MEDIUM` / `FONT_SMALL`。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 七套主题(Themes)
|
## 1. 目录结构
|
||||||
|
|
||||||
颜色数据由 `watchface-tokens.json` 经 `gen_themes.py` 生成到 `source/Themes.mc`(**请勿手改**,改 tokens 后重新生成)。每套主题包含:顶/底弧渐变停止点、未点亮刻度色 `tickOff`、锚点色 `anchorDot`、日期带填充 `bandFill` / 文字 `bandText`、强调色 `accent`、主文字 `textPrimary`、冒号色 `colon`、小时 `hours`、分钟 `minutes`。
|
```
|
||||||
|
fenix8v3-watchface/
|
||||||
|
├── manifest.xml # 应用清单(ID / 支持设备 / 语言)
|
||||||
|
├── monkey.jungle # 构建配置
|
||||||
|
├── design/ # ⭐ 设计稿原件(唯一事实来源)
|
||||||
|
│ ├── watchface-0N-*.svg # 7 套主题的矢量还原稿
|
||||||
|
│ ├── watchface-tokens.json # 配色 token
|
||||||
|
│ └── rebuild.py # 原作者从位图逆向出 SVG 的脚本(仅存档,不参与构建)
|
||||||
|
├── tools/
|
||||||
|
│ ├── gen_themes.py # design/*.svg -> source/Themes.mc
|
||||||
|
│ ├── gen_fields.py # 字段表 -> FieldTable.mc + settings.xml + 两种语言文案
|
||||||
|
│ ├── gen_icons.py # design/*.svg -> 各尺寸图标位图 + 启动图标
|
||||||
|
│ └── gen_preview_svg.py # source/Themes.mc -> preview-themes.svg + preview.html
|
||||||
|
├── source/
|
||||||
|
│ ├── Fenix8V3App.mc # 应用入口(12 行壳子)
|
||||||
|
│ ├── Fenix8V3View.mc # 绘制顺序:进度环 / 各元素 / 常亮模式
|
||||||
|
│ ├── Layout.mc # 500 画布常量、缩放、字号选择、文字绘制
|
||||||
|
│ ├── Icons.mc # 图标:位图三件套 + 其余图元
|
||||||
|
│ ├── Fields.mc # 125 项数据字段的取值与格式化
|
||||||
|
│ ├── Solar.mc # 日出/晨昏蒙影/黄金蓝调时刻的太阳高度角计算
|
||||||
|
│ ├── Owm.mc # OpenWeatherMap 缓存读取
|
||||||
|
│ ├── Fenix8V3Background.mc # 后台服务:拉取 OWM
|
||||||
|
│ ├── Settings.mc # 所有设置项的读取与默认值
|
||||||
|
│ ├── FieldTable.mc # ⚠️ 自动生成:字段 id 表
|
||||||
|
│ └── Themes.mc # ⚠️ 自动生成:七套主题配色
|
||||||
|
├── resources/ # 英文文案 + 设置项定义(部分自动生成)
|
||||||
|
├── resources-chn/ # 简体中文文案
|
||||||
|
├── resources-icons-<宽度>/ # ⚠️ 自动生成:按屏幕尺寸光栅化的图标
|
||||||
|
├── resources-launcher-<尺寸>/ # ⚠️ 自动生成:各尺寸启动图标
|
||||||
|
└── bin/ # 编译产物(已 gitignore)
|
||||||
|
```
|
||||||
|
|
||||||
| # | 主题 | 主色调 | 分钟数字 | 日期带 |
|
重新生成全部派生文件:
|
||||||
|---|------|--------|----------|--------|
|
|
||||||
| 1 | Ember | 橙红渐变(黄→红) | `0xFCA901` | 橙底黑字 |
|
|
||||||
| 2 | Aurora | 青绿渐变(黄绿→青) | `0xAAF701` | 深灰底白字 |
|
|
||||||
| 3 | Brass | 金铜渐变 | `0xECCC93` | 深棕底白字 |
|
|
||||||
| 4 | Voltage | 蓝→黄渐变 | `0x52A8FD` | 蓝底黑字 |
|
|
||||||
| 5 | Reef | 橙→青渐变 | `0xFC7801` | 青底黑字 |
|
|
||||||
| 6 | Acid | 黄绿(近单色) | `0xC4F900` | 深灰底黑字 |
|
|
||||||
| 7 | Kelp | 橙→绿渐变 | `0xF4880A` | 深灰底黑字 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 可配置设置(Garmin Connect / 表盘设置)
|
|
||||||
|
|
||||||
通过 `resources/settings/` 暴露,键名与默认值见 `properties.xml`:
|
|
||||||
|
|
||||||
| 设置 | 键 | 可选值 | 默认 |
|
|
||||||
|------|----|--------|------|
|
|
||||||
| 主题 | `Theme` | 1–7(Ember…Kelp) | 1 |
|
|
||||||
| 顶右弧指标 | `RingTR` | 0 步数 / 1 卡路里 / 2 距离 / 3 楼层 / 4 动动条 | 0 |
|
|
||||||
| 顶左弧指标 | `RingTL` | 同上 | 1 |
|
|
||||||
| 底右弧指标 | `RingBR` | 同上 | 4 |
|
|
||||||
| 底左弧指标 | `RingBL` | 同上 | 3 |
|
|
||||||
| 左上复杂功能 | `LeftTop` | 0 天气 / 1 卡路里 / 2 步数 / 3 距离 / 4 楼层 / 5 电量 | 1 |
|
|
||||||
| 右上复杂功能 | `RightTop` | 同上 | 0 |
|
|
||||||
| 显示顶行 | `ShowTop` | 布尔 | true |
|
|
||||||
| 显示日期带 | `ShowBand` | 布尔 | true |
|
|
||||||
| 显示底行 | `ShowBottom` | 布尔 | true |
|
|
||||||
|
|
||||||
> 指标默认值对应设计稿:**顶右=步数、顶左=卡路里、底右=动动条、底左=楼层**;左上=卡路里、右上=天气。
|
|
||||||
|
|
||||||
### 指标进度换算规则(`metricProgress`)
|
|
||||||
|
|
||||||
| idx | 指标 | 进度分母 |
|
|
||||||
|-----|------|----------|
|
|
||||||
| 0 | 步数 Steps | `stepGoal`(目标步数) |
|
|
||||||
| 1 | 卡路里 Calories | 固定 2000 kcal |
|
|
||||||
| 2 | 距离 Distance | 10 km(1,000,000 cm) |
|
|
||||||
| 3 | 楼层 Floors | `floorsClimbedGoal`(默认 10) |
|
|
||||||
| 4 | 动动条 Move Bar | `moveBarLevel`(归一化 0–1) |
|
|
||||||
|
|
||||||
进度超出 1 会被钳制,弧长即“点亮”刻度数。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 数据源与已知偏差
|
|
||||||
|
|
||||||
| 元素 | 数据来源 | 备注 |
|
|
||||||
|------|----------|------|
|
|
||||||
| 步数 / 卡路里 / 距离 / 楼层 / 动动条 | `ActivityMonitor.getInfo()` | SDK 9.1.0 字段:`steps/stepGoal/calories/distance/floorsClimbed/floorsClimbedGoal/moveBarLevel` |
|
|
||||||
| 天气温度 | `Weather.getCurrentConditions().temperature` | 需手表已配对天气 |
|
|
||||||
| 电量 | `System.getSystemStats().battery` | 0–1 浮点,×100 显示 |
|
|
||||||
| 日期 / 时间 | `Time.Gregorian` | 12 小时制,带 AM/PM |
|
|
||||||
| 蓝牙图标 | `System.getDeviceSettings().phoneConnected`(仅取状态) | 见下方偏差 |
|
|
||||||
|
|
||||||
### ⚠️ 与设计稿的关键偏差
|
|
||||||
|
|
||||||
1. **“日出(sunrise)”元素 → 实时天气温度。** 设计稿顶部复杂功能中有一个日出图标及时间,但 `Weather.CurrentConditions` 在 SDK 9.1.0 **不暴露 sunrise 字段**。因此该位置改为显示**实时天气温度**(`NN°`),图标保留太阳造型。
|
|
||||||
2. **进度环为实时数据**,非设计稿中的静态填充演示值;四段弧指标可在设置中自由分配。
|
|
||||||
3. **蓝牙图标**目前按日期带文字色常显(未根据 `phoneConnected` 切换亮/暗),如需“未连接变暗”可后续在 `drawDateBand` 中据 `ds.phoneConnected` 调色。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 编译 / 构建
|
|
||||||
|
|
||||||
需安装 Connect IQ SDK(本文基于 9.1.0)并把 `monkeyc` 加入 PATH,且准备开发者密钥 `developer_key.der`(已在仓库内)。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 单设备(Enduro 3 主产物)
|
python3 tools/gen_themes.py && python3 tools/gen_fields.py \
|
||||||
monkeyc -o bin/Fenix8V3.prg -f monkey.jungle -y developer_key.der -d enduro3
|
&& python3 tools/gen_icons.py && python3 tools/gen_preview_svg.py
|
||||||
|
```
|
||||||
|
|
||||||
# 导出调试资源(含 -settings.json,便于模拟器载入设置)
|
三份渲染实现(表盘 / SVG 预览 / HTML 预览)只有 **一份颜色数据**:`Themes.mc`。预览脚本直接解析它,不会与固件漂移。字段表、设置项、两种语言的文案同样只有 `tools/gen_fields.py` 里那一张表。
|
||||||
monkeyc -o bin/Fenix8V3.prg -f monkey.jungle -y developer_key.der -d enduro3 -x
|
|
||||||
|
|
||||||
# 多设备批量(已验证可编译)
|
---
|
||||||
for d in enduro3 fenix843mm fenix847mm fenix8pro47mm fenix8solar47mm \
|
|
||||||
fenix8solar51mm epix2 epix2pro47mm epix2pro51mm fenix7 fenix7x \
|
## 1.5 代码结构
|
||||||
fenix7s fr965 fr265 fr255 venu3 vivoactive5; do
|
|
||||||
|
一句话:**位置归 `Layout`,内容归 `Fields`,配色归 `Themes`,设置归 `Settings`,`Fenix8V3View` 只剩画的顺序。**
|
||||||
|
|
||||||
|
| 模块 | 负责 | 不负责 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `Layout` | 500 基准画布的全部坐标常量、`px()` 缩放、极坐标换算、字号选择与自动缩字、渐变文字的分带绘制 | 具体画什么 |
|
||||||
|
| `Icons` | 三个位图图标的加载与染色、电池、蓝牙、其余数据项的图元 | 图标该出现在哪个数据位(由字段 id 决定,但查表在这里) |
|
||||||
|
| `Fields` | 75 项字段的取值、单位换算、本地化文案(星期/月份/标签) | 排版 |
|
||||||
|
| `Settings` | 每个设置项的读取与默认值,包含空值回退 | 设置项的 UI 定义(在 `settings.xml`) |
|
||||||
|
| `Fenix8V3View` | `onUpdate` 的绘制顺序、进度环、常亮模式 | 以上任何一项的细节 |
|
||||||
|
|
||||||
|
`Fields.value()` 按分类拆成 7 个函数(`dateTime` / `activity` / `body` / `system` / `environment` / `weather` / `custom`),每个在不认识 id 时返回 `null`,`value()` 依次尝试。id 是交错的(1、2、3 是活动,8、11、12 是身体,14 又是活动),所以按区间派发行不通。
|
||||||
|
|
||||||
|
`Fields.Ctx` 每次绘制构造一个,把 `ActivityMonitor` / `Weather` / `UserProfile` / `SensorHistory` 的查询**懒加载并缓存**——四个数据位可能都要天气,但只查一次;没有数据位用到 `UserProfile` 时就完全不查。
|
||||||
|
|
||||||
|
|
||||||
|
## 2. 布局(500 基准,取自设计稿 SVG 路径的墨迹包围盒)
|
||||||
|
|
||||||
|
| 元素 | 位置 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 进度环 | 内半径 `231.09`,外半径 `249.10` | 见 §3 |
|
||||||
|
| 电量图标 | `x=192.70`,`cy=53.20`,`26.20×17.81` | 圆角外框 + 按电量填充 + 右侧触点 |
|
||||||
|
| 电量数值 | 左对齐 `x=232.00` | `NN%` |
|
||||||
|
| 上方双数据 | 图标 `cy=90`,数值 `cy=131.06`;左 `x=157.00`,右 `x=343.70` | 居中对齐 |
|
||||||
|
| 时间 | `cy=221.10`;小时右对齐 `x=211.40`,分钟左对齐 `x=276.02` | 竖向渐变,见 §4 |
|
||||||
|
| 冒号 | `x=237.88`,`24.24×21.30`,`y=183.68 / 234.57` | 两块矩形 |
|
||||||
|
| 日期带 | `y=286.30`,高 `44.19`,文字 `cy=308.10` | 蓝牙 `cx=36.88`、星期 `cx=178.05`、月日 `cx=298.28`、AM/PM 右对齐 `465.89` |
|
||||||
|
| 底部数值 | `cy=366.76`;左格右对齐 `234.30`,右格左对齐 `267.40` | 过宽时自动降一档字号 |
|
||||||
|
| 底部标签 | `cy=410.88`,同上对齐 | 跟随所选数据(`DST` / `STEP` / `距离` …) |
|
||||||
|
|
||||||
|
字号按设计稿的**字面高度(cap height)**选取:日期带与电量 `25.34`、上方数据与底部行 `31.50`、时间 `98.70`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 进度环
|
||||||
|
|
||||||
|
刻度是**等角宽的环形扇区**(`fillPolygon` 四点),不是径向线段。
|
||||||
|
|
||||||
|
- **顶弧**:21 根,角度 `6.5 + 4.0k`(k=0..20),角宽 `3.108°`
|
||||||
|
- **底弧**:21 根,角度 `6.005 + 3.0k`,角宽 `2.086°`
|
||||||
|
- **四象限**:右上 `+a`、左上 `-a`、左下 `180+a`、右下 `180-a`(角度为自 12 点起顺时针)
|
||||||
|
- **锚点圆点**:半径 `9.00`,位于圆心距 `240.10`、角度 `0 / 93.15 / 180 / 266.85`
|
||||||
|
|
||||||
|
**点亮方向**(与设计稿一致):两条顶弧从 9 点 / 3 点一侧朝 12 点填充;两条底弧从 6 点朝两侧填充。
|
||||||
|
|
||||||
|
每根刻度的颜色是设计稿里**逐根取样**的实测值(`Themes.RINGTOP[21]` / `RINGBOTTOM[21]`),不是插值近似。
|
||||||
|
|
||||||
|
> **已知近似**:设计稿中底弧最多只点亮 14/21 根,因此 `RINGBOTTOM` 的第 15–21 项无法实测,由 `gen_themes.py` 对最后 8 根做最小二乘线性外推得到。只有当指标超过约 67% 时才会显示这几根。
|
||||||
|
|
||||||
|
### 指标进度换算
|
||||||
|
|
||||||
|
| idx | 指标 | 分母 |
|
||||||
|
|-----|------|------|
|
||||||
|
| 0 | 步数 | `stepGoal` |
|
||||||
|
| 1 | 卡路里 | 固定 2000 kcal |
|
||||||
|
| 2 | 距离 | 10 km |
|
||||||
|
| 3 | 楼层 | `floorsClimbedGoal`(缺省 10) |
|
||||||
|
| 4 | 动动条 | `MOVE_BAR_LEVEL_MAX`(=5) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 时间数字的竖向渐变
|
||||||
|
|
||||||
|
设计稿的小时 / 分钟数字带竖向多段渐变(如 Ember 分钟由 `#FEAA00` 渐变到 `#FF2600`)。Monkey C 无法给文字直接填渐变,实现方式是**同一串数字画 11 遍,每遍用 `dc.setClip` 限制在一条横向色带内**,色带颜色取自 `Themes.HOURS` / `Themes.MINUTES`(各 11 个停止点,实测自设计稿的 `linearGradient`)。首尾两条色带向外延伸,保证字形不会有未绘制的缝隙。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 字号选择
|
||||||
|
|
||||||
|
Connect IQ 只暴露 `getFontAscent` / `getFontDescent` / `getFontHeight`,**没有字面高度接口**,而 ascent 里含有数字和大写字母用不到的变音符空间。因此代码用两个实测比例把 ascent 折算成 cap height:
|
||||||
|
|
||||||
|
```
|
||||||
|
文本字体(Roboto 系) cap ≈ 0.76 × ascent
|
||||||
|
数字字体(Bionic 系) cap ≈ 0.71 × ascent
|
||||||
|
```
|
||||||
|
|
||||||
|
比例是在模拟器里渲染后、从截图上量出墨迹得到的(fenix847mm:`FONT_XTINY` ascent 29 → 大写高 22px;`FONT_NUMBER_MILD` ascent 82 → 数字高 58px)。`pickFont` 取折算后**最接近**目标 cap height 的字体,而不是"最大且不超过"——后者会在设计尺寸落在两档字体之间时白白小一号。
|
||||||
|
|
||||||
|
各机型内置字体不完全相同(Roboto-Regular / RobotoCondensed-Bold / Yantramanav / 各机型位图字体),比例会有几个百分点误差。设计稿使用的是更粗更方的第三方字体,不随包分发,字形差异无法消除。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 七套主题
|
||||||
|
|
||||||
|
由 `tools/gen_themes.py` 从 `design/watchface-0N-*.svg` 生成。每套包含:顶/底弧各 21 根刻度色、未点亮刻度色、锚点色、日期带底/字色、冒号色、小时/分钟各 11 段渐变、强调色(图标与 DST/STEP 标签)、主文字色(各项数值)。
|
||||||
|
|
||||||
|
| # | 主题 | 主色调 | 强调色 | 日期带 |
|
||||||
|
|---|------|--------|--------|--------|
|
||||||
|
| 1 | Ember | 黄 → 红 | `#FEAA05` | 橙底黑字 |
|
||||||
|
| 2 | Aurora | 黄绿 → 青 | `#ADABAE` | 深灰底白字 |
|
||||||
|
| 3 | Brass | 金铜 | `#B57E4B` | 深棕底白字 |
|
||||||
|
| 4 | Voltage | 蓝 → 黄 | `#ADABAE` | 蓝底黑字 |
|
||||||
|
| 5 | Reef | 橙 → 青 | `#FEAA05` | 青底黑字 |
|
||||||
|
| 6 | Acid | 黄绿(近单色) | `#C6FA03` | 深灰底黑字 |
|
||||||
|
| 7 | Kelp | 橙 → 绿 | `#34C294` | 深灰底黑字 |
|
||||||
|
|
||||||
|
改配色:编辑 / 替换 `design/` 里的 SVG,然后
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/gen_themes.py && python3 tools/gen_preview_svg.py
|
||||||
|
```
|
||||||
|
|
||||||
|
新增主题需同步在 `settings.xml` 的 `Theme` 列表与 `strings.xml`(含 `resources-chn/`)中登记。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 可配置设置与数据字段
|
||||||
|
|
||||||
|
19 项设置,在 Garmin Connect 手机 App 的「表盘设置」里改(定义在 `resources/settings/settings.xml`,默认值在 `properties.xml`,两者由 `tools/gen_fields.py` 生成)。
|
||||||
|
|
||||||
|
| 设置 | 键 | 默认 |
|
||||||
|
|------|----|------|
|
||||||
|
| 主题 | `Theme` | 1 (Ember) |
|
||||||
|
| 四段进度环指标 | `RingTR/TL/BR/BL` | 步数 / 卡路里 / 动动条 / 楼层 |
|
||||||
|
| 四个数据位 | `LeftTop` `RightTop` `BottomLeft` `BottomRight` | 日出 / 高低温 / 距离 / 步数 |
|
||||||
|
| 温度单位 | `TempUnit` | 跟随手表 |
|
||||||
|
| 第二时区 1 / 2 偏移(分钟) | `AltOffset1` `AltOffset2` | 0 |
|
||||||
|
| 自定义文字 1 / 2 / 3 | `Custom1/2/3` | 空 |
|
||||||
|
| 显示顶行 / 日期带 / 底行 | `ShowTop` `ShowBand` `ShowBottom` | true |
|
||||||
|
|
||||||
|
进度环指标:步数、卡路里、距离、楼层、动动条、活动分钟(对周目标)、身体电量。
|
||||||
|
|
||||||
|
### 数据字段
|
||||||
|
|
||||||
|
四个数据位共用一张 **125 项** 的字段表,编号沿用 [watchface.io/docs/datafields](https://watchface.io/docs/datafields),两边可以直接对照。底部两格的小标签跟随所选字段自动变化,不需要单独配置。
|
||||||
|
|
||||||
|
| 分类 | 字段 |
|
||||||
|
|------|------|
|
||||||
|
| 日期时间 | 秒(前导零两种)、时间、UTC 时间、第二时区 1/2、星期月日、星期日、月日、日、星期(简写/全称)、月份(名称/数字)、上午下午、周数(ISO / 日历) |
|
||||||
|
| 活动 | 步数、步数取整、剩余步数、卡路里、活动卡路里、活动分钟(总/中强度/高强度/本周)、距离、上楼层数、下楼层数、爬升高度、推行距离、推行次数、**本周与近 7 天累计**(步数/卡路里/距离/楼层/活动分钟) |
|
||||||
|
| 身体 | 心率、静息心率、压力指数、血氧、呼吸频率、身体电量、活动条(正/反向)、体重、BMI、恢复时间、最大摄氧量(跑/骑) |
|
||||||
|
| 系统 | 电量、剩余天数、电量+天数、闹钟、通知、勿扰、蓝牙、太阳能强度 |
|
||||||
|
| 环境 | 海拔、海平面气压、环境气压、日出、日落、下一太阳事件、距太阳事件、月龄、月球照明、民用/航海/天文晨昏蒙影(各 2 项)、黄金时刻(晨/昏)、蓝调时刻(晨/昏) |
|
||||||
|
| 天气 | 气温、最高/最低、体感、最高、最低、湿度、降水概率、风速、风向角、风向、更新时间、海平面气压、紫外线、能见度、露点 |
|
||||||
|
| OpenWeatherMap | 气温、体感、最高/最低、天气描述(简/详)、湿度、气压、能见度、风速、风向角、风向、阵风、1h 降雨、1h 降雪、云量、降水概率、露点、明日与后日气温及高低温、城市名、更新时间(共 25 项) |
|
||||||
|
| 自定义 | 自定义文字 1 / 2 / 3 |
|
||||||
|
|
||||||
|
加字段:在 `tools/gen_fields.py` 的 `FIELDS` 表里加一行,跑一遍生成器,再到 `source/Fields.mc` 的 `value()` 里补一个分支。设置项、两种语言的文案、表盘标签会自动跟上。
|
||||||
|
|
||||||
|
### 秒针与后台天气
|
||||||
|
|
||||||
|
**秒**(850/869)通过 `onPartialUpdate` 每秒重绘:只有当某个数据位真的选了秒时才请求这个回调,并且只在该格的裁剪区内重绘,不动整个表盘。常亮的 AMOLED 机型不参与——那时数据位本来就不显示。
|
||||||
|
|
||||||
|
**OpenWeatherMap** 需要用户在设置里填自己的 API key(免费档即可)。没填就完全不联网,25 个 OWM 字段都显示 `--`。填了之后由后台服务每 30 分钟拉一次(当前天气 + `cnt=16` 的短预报,够算出明日/后日高低温),结果压平成一个小字典存进 `Application.Storage`,表盘只读不联网。
|
||||||
|
|
||||||
|
位置来自天气观测点或最近一次 GPS 定位,并缓存在 Storage 里——两个来源都会间歇性为空,缓存让日出和晨昏蒙影不会一会儿有一会儿没有。
|
||||||
|
|
||||||
|
### ⚠️ 仍然没有实现的,以及原因
|
||||||
|
|
||||||
|
| 没做的 | 原因 |
|
||||||
|
|--------|------|
|
||||||
|
| 各运动项目的 WTD / 近 7 天 / 本月 / 近 28 天 汇总(约 80 项) | Connect IQ 只有 `ActivityMonitor.getHistory()` 的按天总计(步数/卡路里/距离/楼层/活动分钟),**没有**按运动分类的历史。所以通用的本周/近 7 天累计已经做了,按跑步/骑行/游泳分开的做不了——那些在 GreenBlack 那边是手机侧与 Garmin Connect 集成同步来的 |
|
||||||
|
| StormGlass.io 潮汐(5 项) | 需要额外的付费 API key,且与本表盘的定位无关 |
|
||||||
|
| 第三方 App 复杂功能(CGM、Hydration、QuoteGlance 等 12 项) | 需要用户装了对应的 App,并通过 Complications API 订阅 |
|
||||||
|
| 日历事件、计时器、倒计时 | 日历不对 CIQ 开放;表盘拿不到触摸输入,没法做启停 |
|
||||||
|
| 睡眠得分、训练状态、完赛/配速预测 | `ActivityMonitor.Info` 与 `UserProfile` 上没有这些字段(SDK 9.1.0 实测) |
|
||||||
|
| 高尔夫成绩 | 无对应 API |
|
||||||
|
| 标记类字段(Marker,约 35 项) | 那是给刻度型表盘做环形标记用的,本表盘的进度环用的是自己的指标体系 |
|
||||||
|
| OWM 的 AQI 与 UVI | 免费档的 `/data/2.5/weather` 不返回;要 One Call 3.0 订阅。露点已用 Magnus 公式从温湿度算出 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 数据来源与权限
|
||||||
|
|
||||||
|
| 元素 | API |
|
||||||
|
|------|-----|
|
||||||
|
| 步数 / 卡路里 / 距离 / 楼层 / 动动条 / 活动分钟 / 呼吸 / 压力 / 恢复时间 | `ActivityMonitor.getInfo()` |
|
||||||
|
| 心率 | `Activity.getActivityInfo().currentHeartRate`,回退 `ActivityMonitor.getHeartRateHistory()` |
|
||||||
|
| 血氧 / 身体电量 | `SensorHistory`(需 `SensorHistory` 权限) |
|
||||||
|
| 静息心率 / 体重 / 身高 / 最大摄氧量 | `UserProfile.getProfile()`(需 `UserProfile` 权限) |
|
||||||
|
| 海拔 / 环境气压 / 海平面气压 | `Activity.getActivityInfo()` |
|
||||||
|
| 日出 / 日落 | `Weather.getSunrise/getSunset(location, time)`,位置取 `CurrentConditions.observationLocationPosition`,回退 `Activity.currentLocation` |
|
||||||
|
| 气温 / 体感 / 湿度 / 降水 / 风 | `Weather.getCurrentConditions()` |
|
||||||
|
| 最高 / 最低气温 | `Weather.getDailyForecast()[0]` |
|
||||||
|
| 电量 / 剩余天数 / 太阳能 | `System.getSystemStats()`(`battery` 是 0–100 的百分比,不是 0–1) |
|
||||||
|
| 闹钟 / 通知 / 勿扰 / 蓝牙 / 单位制 | `System.getDeviceSettings()` |
|
||||||
|
| 月龄 / 月球照明 | 自算(儒略日 + 朔望月 29.530588853 天) |
|
||||||
|
| 周数 | 自算(ISO 8601 与日历周两种) |
|
||||||
|
|
||||||
|
### 权限
|
||||||
|
|
||||||
|
`manifest.xml` 声明四项:`UserProfile`(静息心率、体重、VO2max)、`SensorHistory`(血氧、身体电量)、`Communications` 与 `Background`(OpenWeatherMap)。用户安装时会看到授权提示。
|
||||||
|
|
||||||
|
> `Sensor` 权限**不能**用于 `type="watchface"`(除非同时申请 `Background`),编译器会直接拒绝。所以海拔与气压走 `Activity.Info` 而不是 `Sensor.getInfo()`。
|
||||||
|
|
||||||
|
单位与制式跟随手表:`is24Hour`、`distanceUnits`、`temperatureUnits`(温度可用 `TempUnit` 覆盖)。天气与日出需手表已配对并同步过数据,否则显示 `--`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8.2 常亮显示(AOD)
|
||||||
|
|
||||||
|
AMOLED 机型在常亮模式下必须压低点亮像素,否则既费电又有烧屏风险,Garmin 上架审核也会卡。`onEnterSleep` / `onExitSleep` 切换到 `drawAmbient()`:
|
||||||
|
|
||||||
|
- 不画日期带底色(整条亮橙色横幅是最费电的元素)
|
||||||
|
- 进度环只画**点亮的**刻度,且整体压暗到 45%
|
||||||
|
- 时间与日期压暗到 55%,其余数据位全部隐藏
|
||||||
|
- 整个画面按分钟在 ±4px 内游走,避免固定像素长期点亮
|
||||||
|
|
||||||
|
只在 `DeviceSettings.requiresBurnInProtection` 为真时启用;MIP 机型(Fenix 7 / Enduro 3 等)不受影响,照常全量绘制。
|
||||||
|
|
||||||
|
模拟器可用 `Settings → Display Mode` 切换验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8.5 多语言
|
||||||
|
|
||||||
|
`manifest.xml` 声明了 `eng` 与 `chn`,两套文案分别在 `resources/strings/` 与 `resources-chn/strings/`,由 `monkey.jungle` 的 `base.resourcePath = resources;resources-chn` 同时纳入编译(目录名的 `-chn` 后缀就是语言标记)。手表会按系统语言自动选择。
|
||||||
|
|
||||||
|
**表盘上的文字也走资源**,不是硬编码:
|
||||||
|
|
||||||
|
| 资源 | 英文 | 中文 |
|
||||||
|
|------|------|------|
|
||||||
|
| `DowNames` | `SUN,MON,…,SAT` | `周日,周一,…,周六` |
|
||||||
|
| `MonNames` | `JAN,FEB,…,DEC` | `1月,2月,…,12月` |
|
||||||
|
| `DateFormat` | `$1$ $2$` → `SEP 9` | `$1$$2$日` → `9月9日` |
|
||||||
|
| `Meridiem` | `AM,PM` | `上午,下午` |
|
||||||
|
| `FieldLabels` | `TEMP,KCAL,STEP,DST,…` | `气温,卡路里,步数,距离,…` |
|
||||||
|
|
||||||
|
逗号分隔的列表在 `loadStrings()` 里一次性读入并拆分(`loadResource` 每次调用开销不低,不宜放进 `onUpdate`)。
|
||||||
|
|
||||||
|
> 中文字形依赖手表固件里的 APAC 字体包(`fontSet: apac_twcn` / `apac_chn`)。国行机型自带;国际版若未安装中文字体包,会退回英文或显示方块。模拟器可用 `Settings → Language → Chinese (Simplified)` 验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 构建
|
||||||
|
|
||||||
|
需安装 Connect IQ SDK 9.1.0,`monkeyc` 在 PATH 中,并备好 `developer_key.der`(**不在仓库内**,需自行生成或提供)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
monkeyc -o bin/Fenix8V3.prg -f monkey.jungle -y developer_key.der -d fenix847mm
|
||||||
|
```
|
||||||
|
|
||||||
|
全部 17 款设备批量编译(已验证通过):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for d in enduro3 fenix843mm fenix847mm fenix8pro47mm fenix8solar47mm fenix8solar51mm \
|
||||||
|
epix2 epix2pro47mm epix2pro51mm fenix7 fenix7x fenix7s fr965 fr265 fr255 \
|
||||||
|
venu3 vivoactive5; do
|
||||||
monkeyc -o "bin/Fenix8V3_${d}.prg" -f monkey.jungle -y developer_key.der -d "$d" \
|
monkeyc -o "bin/Fenix8V3_${d}.prg" -f monkey.jungle -y developer_key.der -d "$d" \
|
||||||
&& echo "[$d] OK" || echo "[$d] FAIL"
|
&& echo "[$d] OK" || echo "[$d] FAIL"
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
已验证编译通过的设备:`enduro3, fenix843mm, fenix847mm, fenix8pro47mm, fenix8solar47mm, fenix8solar51mm, epix2, epix2pro47mm, epix2pro51mm, fenix7, fenix7x, fenix7s, fr965, fr265, fr255, venu3, vivoactive5`。产物位于 `bin/`,每个设备对应一个 `.prg` 及 `.prg.debug.xml`(调试版含资源镜像)。
|
屏幕分辨率跨度较大(fenix7s 240×240 至 fenix847mm / venu3 454×454),全部按 `s = width/500` 缩放。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 模拟器与侧载
|
## 10. 模拟器与侧载
|
||||||
|
|
||||||
1. **Connect IQ Simulator**(Garmin 自带):`File → Open App Project` 选本目录,选设备后 `Run`。在 `bin/*.prg.debug.xml` 旁会生成 `-settings.json`,可在模拟器菜单 `View → Settings` 载入并实时切换主题/指标验证。
|
```bash
|
||||||
2. **真机侧载(Garmin Connect / Garmin Express)**:
|
connectiq & # 启动模拟器
|
||||||
- 用 Garmin Express 或 Connect 手机 App 的“侧载(sideload)”功能导入对应设备的 `bin/Fenix8V3_<device>.prg`;
|
monkeydo bin/Fenix8V3_fenix847mm.prg fenix847mm # 载入表盘
|
||||||
- 或把 `.prg` 拷入手表的 `/GARMIN/Apps/` 目录(需开发者模式 / WiFi 文件传输)。
|
|
||||||
3. 安装后在手表“表盘”列表选择 **Fenix 8 V3**,长按进入设置即可调主题与指标。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
fenix8v3-watchface/
|
|
||||||
├── manifest.xml # 应用清单(ID / 支持设备 / 语言)
|
|
||||||
├── monkey.jungle # 构建配置(manifest + 源码/资源路径)
|
|
||||||
├── developer_key.der # Connect IQ 开发者签名密钥
|
|
||||||
├── source/
|
|
||||||
│ ├── Fenix8V3App.mc # 应用入口:getInitialView / onSettingsChanged
|
|
||||||
│ ├── Fenix8V3View.mc # 表盘渲染主逻辑(onUpdate + 各 draw*)
|
|
||||||
│ └── Themes.mc # ⚠️ 自动生成:7 套主题颜色(勿手改)
|
|
||||||
├── resources/
|
|
||||||
│ ├── strings/strings.xml # AppName / 设置项 / 主题 / 指标 / 字段文案
|
|
||||||
│ └── settings/
|
|
||||||
│ ├── properties.xml # 设置默认值
|
|
||||||
│ └── settings.xml # 设置 UI 定义(列表/布尔)
|
|
||||||
└── bin/ # 编译产物(.prg + 调试 xml + settings.json)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
非 release 编译会同时产出 `bin/*-settings.json`,可在模拟器 `View → Settings` 载入以实时切换主题与指标。注意模拟器会**持久化**已保存的设置,改 `properties.xml` 的默认值对已运行过的实例不生效,需先重置应用数据。
|
||||||
|
|
||||||
## 8. 二次开发与扩展
|
真机侧载:用 Garmin Express 或 Connect 手机 App 导入对应机型的 `bin/Fenix8V3_<device>.prg`,或把 `.prg` 拷入手表 `/GARMIN/Apps/`。
|
||||||
|
|
||||||
- **改主题配色**:编辑 `watchface-tokens.json`(设计稿目录下),运行 `gen_themes.py` 重新生成 `source/Themes.mc`。新增主题时同步在 `Themes.mc` 的 `ORDER` 数组、`settings.xml` 的 `Theme` 列表、`strings.xml` 的 `ThemeXX` 文案中登记。
|
|
||||||
- **加指标**:在 `metricProgress`(进度换算)与 `complicationValue`(复杂功能文本)两个方法里按 `idx` 分支扩展,并在 `settings.xml` / `properties.xml` 的列表项补充。
|
|
||||||
- **调布局**:所有坐标集中在 `Fenix8V3View.mc` 的 `draw*` 方法里,均为 “设计像素 × `s`” 形式,改一处即可全局缩放生效。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 设计稿来源
|
## 11. 预览
|
||||||
|
|
||||||
还原基准为 `watchface-kit.zip` 内的 8 张 SVG 变体 + `watchface-tokens.json`(配色)+ `watchface-spec.html`(像素测量规范)+ `rebuild.py`(几何常量)。本仓库为按该规范落地的 Monkey C 实现,设计版权归原作者所有。
|
```bash
|
||||||
|
python3 tools/gen_preview_svg.py
|
||||||
|
```
|
||||||
|
|
||||||
|
产出 `preview-themes.svg`(七套主题一览)与 `preview.html`(可切换主题的单页)。两者用网页字体渲染文字,**几何与配色可信,字形不可信** —— 真机字形以模拟器截图为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 与设计稿的差异
|
||||||
|
|
||||||
|
1. **字体**。设计稿用的是一款更粗、更方的第三方字体;Garmin 只提供内置 Roboto / Bionic 系列,且各机型不同。字号按 cap height 对齐,字形无法一致。
|
||||||
|
2. **图标**。设计稿里的图标是位图描出的密集矢量路径(数百个点),不适合逐帧绘制;代码用圆、多边形、线段按实测的子形状位置重建了电量、日出/日落、多云三个图标。其余数据项(卡路里 / 步数 / 距离 / 楼层)设计稿未提供图标,为自绘。
|
||||||
|
3. **底弧高段颜色**为外推值,见 §3。
|
||||||
|
4. **进度环是实时数据**,设计稿中的是固定演示状态(右上 21/21、左上 10/21、右下 9/21、左下 14/21)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 设计稿版权
|
||||||
|
|
||||||
|
`design/` 内的 SVG、tokens 与 `rebuild.py` 来自 `watchface-kit`,版权归原作者所有。本仓库是按该设计稿实现的 Monkey C 表盘。
|
||||||
|
|||||||
329
design/rebuild.py
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Watch face vector reconstruction, v2.
|
||||||
|
|
||||||
|
Changes over v1:
|
||||||
|
* ring ticks are annular sectors (constant angular width) instead of rects
|
||||||
|
* every tick's colour is sampled directly from the source, no LUT interpolation
|
||||||
|
* glyph alpha is normalised against the *local* fill colour, so vertically
|
||||||
|
graded digits keep their true weight in the dark rows
|
||||||
|
* time digits get multi-stop gradients sampled every 8 px
|
||||||
|
* sub-pixel geometry throughout
|
||||||
|
"""
|
||||||
|
import json, math, os
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
from skimage import measure
|
||||||
|
|
||||||
|
SRC = "/mnt/user-data/uploads"
|
||||||
|
OUT = "/mnt/user-data/outputs"
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
|
||||||
|
CX_IX, CY_IX = 250.038, 250.049 # dial centre, pixel-index space
|
||||||
|
CX, CY = CX_IX + 0.5, CY_IX + 0.5 # same point in SVG user space
|
||||||
|
PX = 0.5 # index -> SVG offset
|
||||||
|
|
||||||
|
THEMES = [
|
||||||
|
("0fbde6715a76f357bcc5c20cff74c0bf2f156e89.png", "01", "Ember"),
|
||||||
|
("304a904845dbc44aa0c87eecad2dd3a2621d2ffc.png", "02", "Aurora"),
|
||||||
|
("a61e76acdc7f17edfda3b11d36ba5d1c25e0739b.png", "03", "Brass"),
|
||||||
|
("f43ad1228487aa0261183e7268277f0e9ab328f4.png", "04", "Voltage"),
|
||||||
|
("8fd4a2811dc0227b004a5832479cc0a5d4e26a9f.png", "05", "Reef"),
|
||||||
|
("f087f3345e5036bfd406719e8d7517c13109d998.png", "06", "Acid"),
|
||||||
|
("d3a77eea386551ff09b352a77f522bb3c68e925d.png", "07", "Kelp"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- ring geometry ---
|
||||||
|
R_IN, R_OUT = 231.09, 249.10
|
||||||
|
|
||||||
|
TOP_PHASE, TOP_PITCH, TOP_W, TOP_N = 6.500, 4.000, 3.108, 21
|
||||||
|
BOT_PHASE, BOT_PITCH, BOT_W, BOT_N = 6.005, 3.000, 2.086, 21
|
||||||
|
|
||||||
|
DOT_R, DOT_RAD = 240.10, 9.00
|
||||||
|
DOT_ANGLES = [0.0, 93.15, 180.0, 266.85]
|
||||||
|
|
||||||
|
BAND_Y, BAND_H = 286.30, 44.19
|
||||||
|
COLON = [(237.88, 183.68, 24.24, 21.30), (237.88, 234.57, 24.24, 21.30)]
|
||||||
|
|
||||||
|
|
||||||
|
def tick_angles():
|
||||||
|
"""(group, signed centre angle) for all 86 ticks."""
|
||||||
|
out = []
|
||||||
|
for k in range(TOP_N):
|
||||||
|
a = TOP_PHASE + TOP_PITCH * k
|
||||||
|
out.append(("top-right", a, TOP_W))
|
||||||
|
out.append(("top-left", -a, TOP_W))
|
||||||
|
for k in range(BOT_N):
|
||||||
|
a = BOT_PHASE + BOT_PITCH * k
|
||||||
|
out.append(("bottom-left", 180.0 + a, BOT_W))
|
||||||
|
out.append(("bottom-right", 180.0 - a, BOT_W))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def polar(ang, r):
|
||||||
|
t = math.radians(ang - 90.0)
|
||||||
|
return CX + math.cos(t) * r, CY + math.sin(t) * r
|
||||||
|
|
||||||
|
|
||||||
|
def polar_ix(ang, r):
|
||||||
|
t = math.radians(ang - 90.0)
|
||||||
|
return CX_IX + math.cos(t) * r, CY_IX + math.sin(t) * r
|
||||||
|
|
||||||
|
|
||||||
|
def sector_path(centre, width, r0=R_IN, r1=R_OUT):
|
||||||
|
a0, a1 = centre - width / 2, centre + width / 2
|
||||||
|
x1, y1 = polar(a0, r1)
|
||||||
|
x2, y2 = polar(a1, r1)
|
||||||
|
x3, y3 = polar(a1, r0)
|
||||||
|
x4, y4 = polar(a0, r0)
|
||||||
|
return (f"M{x1:.2f},{y1:.2f} A{r1:.2f},{r1:.2f} 0 0 1 {x2:.2f},{y2:.2f} "
|
||||||
|
f"L{x3:.2f},{y3:.2f} A{r0:.2f},{r0:.2f} 0 0 0 {x4:.2f},{y4:.2f} Z")
|
||||||
|
|
||||||
|
|
||||||
|
def sample_tick(arr, centre, width):
|
||||||
|
"""Median colour of a tick's interior (AA edges excluded)."""
|
||||||
|
px = []
|
||||||
|
for r in np.arange(R_IN + 3.5, R_OUT - 3.5, 1.0):
|
||||||
|
for d in np.arange(-width * 0.32, width * 0.32 + 1e-9, width * 0.16):
|
||||||
|
x, y = polar_ix(centre + d, r)
|
||||||
|
px.append(arr[int(round(y)), int(round(x))])
|
||||||
|
return np.median(np.array(px), axis=0)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- tracing ---
|
||||||
|
def rdp(pts, eps):
|
||||||
|
if len(pts) < 3:
|
||||||
|
return pts
|
||||||
|
a, b = pts[0], pts[-1]
|
||||||
|
ab = b - a
|
||||||
|
n = math.hypot(ab[0], ab[1])
|
||||||
|
if n < 1e-9:
|
||||||
|
d = np.hypot(*(pts - a).T)
|
||||||
|
else:
|
||||||
|
d = np.abs(ab[0] * (pts[:, 1] - a[1]) - ab[1] * (pts[:, 0] - a[0])) / n
|
||||||
|
i = int(np.argmax(d))
|
||||||
|
if d[i] > eps:
|
||||||
|
return np.vstack([rdp(pts[: i + 1], eps)[:-1], rdp(pts[i:], eps)])
|
||||||
|
return np.vstack([a, b])
|
||||||
|
|
||||||
|
|
||||||
|
def trace(alpha, x0, y0, eps=0.07, ss=6):
|
||||||
|
half = 0.5 / ss # supersampled index -> source continuous coordinate
|
||||||
|
up = np.asarray(Image.fromarray((np.clip(alpha, 0, 1) * 255).astype(np.uint8))
|
||||||
|
.resize((alpha.shape[1] * ss, alpha.shape[0] * ss), Image.BICUBIC)) / 255.0
|
||||||
|
up = np.pad(up, 1, mode="constant")
|
||||||
|
parts = []
|
||||||
|
for c in measure.find_contours(up, 0.5):
|
||||||
|
c = (c - 1) / ss
|
||||||
|
pts = np.column_stack([c[:, 1] + x0 + half, c[:, 0] + y0 + half])
|
||||||
|
if len(pts) < 10:
|
||||||
|
continue
|
||||||
|
pts = rdp(pts, eps)
|
||||||
|
if len(pts) < 4:
|
||||||
|
continue
|
||||||
|
parts.append("M" + " ".join(f"{x:.2f},{y:.2f}" for x, y in pts[:-1]) + "Z")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def full_colour(reg, q=0.97):
|
||||||
|
"""Colour of a fully-opaque pixel in a solid-filled region."""
|
||||||
|
flat = reg.reshape(-1, 3)
|
||||||
|
l = flat.mean(axis=1)
|
||||||
|
cut = np.quantile(l, q)
|
||||||
|
sel = flat[l >= cut]
|
||||||
|
return sel.mean(axis=0)
|
||||||
|
|
||||||
|
|
||||||
|
def alpha_solid(arr, box):
|
||||||
|
x0, y0, x1, y1 = box
|
||||||
|
reg = arr[y0:y1, x0:x1].astype(float)
|
||||||
|
e = full_colour(reg)
|
||||||
|
a = reg.mean(axis=2) / max(e.mean(), 1e-6)
|
||||||
|
return np.clip(a, 0, 1), e, x0, y0
|
||||||
|
|
||||||
|
|
||||||
|
def row_ramp(reg):
|
||||||
|
"""Per-row fully-opaque colour for a vertically graded glyph."""
|
||||||
|
h = reg.shape[0]
|
||||||
|
ys, cols = [], []
|
||||||
|
for y in range(h):
|
||||||
|
row = reg[y]
|
||||||
|
l = row.mean(axis=1)
|
||||||
|
if l.max() < 30:
|
||||||
|
continue
|
||||||
|
sel = row[l >= l.max() * 0.92]
|
||||||
|
if len(sel) < 3:
|
||||||
|
continue
|
||||||
|
ys.append(y)
|
||||||
|
cols.append(sel.mean(axis=0))
|
||||||
|
if len(ys) < 4:
|
||||||
|
return None, None
|
||||||
|
ys = np.array(ys)
|
||||||
|
cols = np.array(cols)
|
||||||
|
# smooth then extend to every row
|
||||||
|
out = np.zeros((h, 3))
|
||||||
|
for c in range(3):
|
||||||
|
out[:, c] = np.interp(np.arange(h), ys, np.convolve(
|
||||||
|
cols[:, c], np.ones(5) / 5, mode="same") if len(ys) > 8 else cols[:, c])
|
||||||
|
out[: ys[0], c] = cols[0, c]
|
||||||
|
out[ys[-1] + 1:, c] = cols[-1, c]
|
||||||
|
return out, (ys[0], ys[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def alpha_graded(arr, box):
|
||||||
|
x0, y0, x1, y1 = box
|
||||||
|
reg = arr[y0:y1, x0:x1].astype(float)
|
||||||
|
ramp, span = row_ramp(reg)
|
||||||
|
if ramp is None:
|
||||||
|
return alpha_solid(arr, box) + (None,)
|
||||||
|
denom = np.maximum(ramp.mean(axis=1), 1e-6)[:, None]
|
||||||
|
a = np.clip(reg.mean(axis=2) / denom, 0, 1)
|
||||||
|
return a, ramp, x0, y0, span
|
||||||
|
|
||||||
|
|
||||||
|
def alpha_on_fill(arr, box, fill):
|
||||||
|
x0, y0, x1, y1 = box
|
||||||
|
reg = arr[y0:y1, x0:x1].astype(float)
|
||||||
|
fill = np.array(fill, float)
|
||||||
|
d = reg - fill
|
||||||
|
far = d.reshape(-1, 3)
|
||||||
|
mag = np.linalg.norm(far, axis=1)
|
||||||
|
e = far[mag >= np.quantile(mag, 0.97)].mean(axis=0)
|
||||||
|
a = (d @ e) / max(e @ e, 1e-6)
|
||||||
|
return np.clip(a, 0, 1), fill + e, x0, y0
|
||||||
|
|
||||||
|
|
||||||
|
def hx(c):
|
||||||
|
return "#%02X%02X%02X" % tuple(int(round(min(255, max(0, v)))) for v in c)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- elements ---
|
||||||
|
SOLID = [
|
||||||
|
("battery-icon", (188, 38, 228, 68)),
|
||||||
|
("battery-value", (228, 38, 312, 70)),
|
||||||
|
("sunrise-icon", (136, 72, 178, 108)),
|
||||||
|
("sunrise-value", (106, 110, 208, 152)),
|
||||||
|
("weather-icon", (322, 70, 364, 108)),
|
||||||
|
("weather-value", (258, 110, 428, 152)),
|
||||||
|
("dist-value", (166, 345, 240, 388)),
|
||||||
|
("dist-label", (143, 390, 238, 432)),
|
||||||
|
("steps-value", (261, 345, 366, 388)),
|
||||||
|
("steps-label", (263, 390, 386, 432)),
|
||||||
|
]
|
||||||
|
BAND_EL = [
|
||||||
|
("bt-icon", (20, 289, 54, 326)),
|
||||||
|
("date-day", (132, 290, 223, 326)),
|
||||||
|
("date-md", (225, 290, 370, 326)),
|
||||||
|
("meridiem", (404, 290, 472, 326)),
|
||||||
|
]
|
||||||
|
TIME_L = (76, 162, 218, 278)
|
||||||
|
TIME_R = (264, 162, 434, 278)
|
||||||
|
|
||||||
|
|
||||||
|
def grad_def(gid, ramp, y0, span):
|
||||||
|
ya, yb = y0 + span[0] + 0.5, y0 + span[1] + 0.5
|
||||||
|
n = max(2, int((span[1] - span[0]) // 8))
|
||||||
|
stops = []
|
||||||
|
for i in range(n + 1):
|
||||||
|
f = i / n
|
||||||
|
yy = int(round(span[0] + f * (span[1] - span[0])))
|
||||||
|
stops.append(f'<stop offset="{f:.3f}" stop-color="{hx(ramp[yy])}"/>')
|
||||||
|
return (f'<linearGradient id="{gid}" x1="0" y1="{ya}" x2="0" y2="{yb}" '
|
||||||
|
f'gradientUnits="userSpaceOnUse">{"".join(stops)}</linearGradient>')
|
||||||
|
|
||||||
|
|
||||||
|
def build(path, tid, tname):
|
||||||
|
arr = np.array(Image.open(os.path.join(SRC, path)).convert("RGB")).astype(int)
|
||||||
|
defs, parts, tok = [], [], {}
|
||||||
|
|
||||||
|
parts.append('<rect id="background" width="500" height="500" fill="#000000"/>')
|
||||||
|
|
||||||
|
# ---- ring: every tick measured individually
|
||||||
|
groups = {}
|
||||||
|
for gname, centre, width in tick_angles():
|
||||||
|
raw = sample_tick(arr, centre, width)
|
||||||
|
if raw.mean() < 12: # slot occupied by an anchor dot / not drawn
|
||||||
|
continue
|
||||||
|
col = hx(raw)
|
||||||
|
groups.setdefault(gname, []).append(
|
||||||
|
f'<path d="{sector_path(centre, width)}" fill="{col}"/>')
|
||||||
|
ring = []
|
||||||
|
for g in ("top-right", "top-left", "bottom-left", "bottom-right"):
|
||||||
|
ring.append(f'<g id="ring-{g}">\n ' + "\n ".join(groups[g]) + "\n </g>")
|
||||||
|
dot_col = hx(np.median(arr[4:13, 245:255].reshape(-1, 3), axis=0))
|
||||||
|
dots = []
|
||||||
|
for a in DOT_ANGLES:
|
||||||
|
x, y = polar(a, DOT_R)
|
||||||
|
dots.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="{DOT_RAD}" fill="{dot_col}"/>')
|
||||||
|
ring.append('<g id="ring-anchors">\n ' + "\n ".join(dots) + "\n </g>")
|
||||||
|
parts.append('<g id="ring">\n ' + "\n ".join(ring) + "\n</g>")
|
||||||
|
tok["anchorDot"] = dot_col
|
||||||
|
|
||||||
|
# ---- date band
|
||||||
|
fill = arr[300, 110].astype(float)
|
||||||
|
band = [f'<rect x="0" y="{BAND_Y}" width="500" height="{BAND_H}" fill="{hx(fill)}"/>']
|
||||||
|
band_text = None
|
||||||
|
for name, box in BAND_EL:
|
||||||
|
a, e, x0, y0 = alpha_on_fill(arr, box, fill)
|
||||||
|
d = trace(a, x0, y0)
|
||||||
|
if not d:
|
||||||
|
continue
|
||||||
|
band_text = hx(e)
|
||||||
|
band.append(f'<path id="{name}" d="{d}" fill="{band_text}" fill-rule="evenodd"/>')
|
||||||
|
parts.append('<g id="date-band">\n ' + "\n ".join(band) + "\n</g>")
|
||||||
|
tok["bandFill"], tok["bandText"] = hx(fill), band_text
|
||||||
|
|
||||||
|
# ---- time
|
||||||
|
tg = []
|
||||||
|
for gid, name, box in (("gHours", "time-hours", TIME_L),
|
||||||
|
("gMinutes", "time-minutes", TIME_R)):
|
||||||
|
a, ramp, x0, y0, span = alpha_graded(arr, box)
|
||||||
|
if span is None:
|
||||||
|
continue
|
||||||
|
defs.append(grad_def(gid, ramp, y0, span))
|
||||||
|
tok[gid] = [hx(ramp[span[0]]), hx(ramp[span[1]])]
|
||||||
|
tg.append((name, f'<path id="{name}" d="{trace(a, x0, y0)}" '
|
||||||
|
f'fill="url(#{gid})" fill-rule="evenodd"/>'))
|
||||||
|
colon_col = hx(np.median(arr[188:200, 242:258].reshape(-1, 3), axis=0))
|
||||||
|
tok["colon"] = colon_col
|
||||||
|
colon = '<g id="time-colon">' + "".join(
|
||||||
|
f'<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{colon_col}"/>'
|
||||||
|
for x, y, w, h in COLON) + "</g>"
|
||||||
|
parts.append('<g id="time">\n ' + tg[0][1] + "\n " + colon + "\n " + tg[1][1] + "\n</g>")
|
||||||
|
|
||||||
|
# ---- complications
|
||||||
|
comp = []
|
||||||
|
for name, box in SOLID:
|
||||||
|
a, e, x0, y0 = alpha_solid(arr, box)
|
||||||
|
d = trace(a, x0, y0)
|
||||||
|
if not d:
|
||||||
|
continue
|
||||||
|
comp.append(f'<path id="{name}" d="{d}" fill="{hx(e)}" fill-rule="evenodd"/>')
|
||||||
|
tok[name] = hx(e)
|
||||||
|
parts.append('<g id="complications">\n ' + "\n ".join(comp) + "\n</g>")
|
||||||
|
|
||||||
|
svg = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="500" '
|
||||||
|
'height="500" shape-rendering="geometricPrecision">\n'
|
||||||
|
f'<title>Watch face {tid} {tname}</title>\n'
|
||||||
|
f'<defs>{"".join(defs)}</defs>\n' + "\n".join(parts) + "\n</svg>\n")
|
||||||
|
p = os.path.join(OUT, f"watchface-{tid}-{tname.lower()}.svg")
|
||||||
|
open(p, "w").write(svg)
|
||||||
|
|
||||||
|
# ring colour table for the spec
|
||||||
|
tok["ringTop"] = {f"{TOP_PHASE + TOP_PITCH * k:.2f}":
|
||||||
|
hx(sample_tick(arr, TOP_PHASE + TOP_PITCH * k, TOP_W))
|
||||||
|
for k in range(TOP_N)}
|
||||||
|
tok["ringBottom"] = {f"{BOT_PHASE + BOT_PITCH * k:.2f}":
|
||||||
|
hx(sample_tick(arr, 180 + BOT_PHASE + BOT_PITCH * k, BOT_W))
|
||||||
|
for k in range(BOT_N)}
|
||||||
|
return p, tok
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
tokens = {}
|
||||||
|
for p, tid, tname in THEMES:
|
||||||
|
out, tok = build(p, tid, tname)
|
||||||
|
tok["name"] = tname
|
||||||
|
tokens[tid] = tok
|
||||||
|
print("wrote", out)
|
||||||
|
json.dump(tokens, open(os.path.join(OUT, "watchface-tokens.json"), "w"), indent=2)
|
||||||
129
design/watchface-01-ember.svg
Normal file
|
After Width: | Height: | Size: 59 KiB |
129
design/watchface-02-aurora.svg
Normal file
|
After Width: | Height: | Size: 59 KiB |
129
design/watchface-03-brass.svg
Normal file
|
After Width: | Height: | Size: 59 KiB |
129
design/watchface-04-voltage.svg
Normal file
|
After Width: | Height: | Size: 60 KiB |
129
design/watchface-05-reef.svg
Normal file
|
After Width: | Height: | Size: 60 KiB |
129
design/watchface-06-acid.svg
Normal file
|
After Width: | Height: | Size: 59 KiB |
129
design/watchface-07-kelp.svg
Normal file
|
After Width: | Height: | Size: 59 KiB |
499
design/watchface-tokens.json
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
{
|
||||||
|
"01": {
|
||||||
|
"anchorDot": "#525551",
|
||||||
|
"bandFill": "#FEAA00",
|
||||||
|
"bandText": "#000002",
|
||||||
|
"gHours": [
|
||||||
|
"#6F6F6F",
|
||||||
|
"#4A4A4B"
|
||||||
|
],
|
||||||
|
"gMinutes": [
|
||||||
|
"#7E5501",
|
||||||
|
"#931500"
|
||||||
|
],
|
||||||
|
"colon": "#525551",
|
||||||
|
"battery-icon": "#FEAA03",
|
||||||
|
"battery-value": "#FFFFFF",
|
||||||
|
"sunrise-icon": "#FEAA01",
|
||||||
|
"sunrise-value": "#FFFFFF",
|
||||||
|
"weather-icon": "#FEAA05",
|
||||||
|
"weather-value": "#FFFFFF",
|
||||||
|
"dist-value": "#FFFFFF",
|
||||||
|
"dist-label": "#FEAB05",
|
||||||
|
"steps-value": "#FFFFFF",
|
||||||
|
"steps-label": "#FEAA05",
|
||||||
|
"ringTop": {
|
||||||
|
"6.50": "#FFF200",
|
||||||
|
"10.50": "#FFE601",
|
||||||
|
"14.50": "#FFDB01",
|
||||||
|
"18.50": "#FFCE00",
|
||||||
|
"22.50": "#FFC200",
|
||||||
|
"26.50": "#FEB600",
|
||||||
|
"30.50": "#FEAA00",
|
||||||
|
"34.50": "#FF9D01",
|
||||||
|
"38.50": "#FF9100",
|
||||||
|
"42.50": "#FE8600",
|
||||||
|
"46.50": "#FE7900",
|
||||||
|
"50.50": "#FE6E00",
|
||||||
|
"54.50": "#FF6100",
|
||||||
|
"58.50": "#FF5500",
|
||||||
|
"62.50": "#FF4801",
|
||||||
|
"66.50": "#FF3C01",
|
||||||
|
"70.50": "#FF3002",
|
||||||
|
"74.50": "#FF2600",
|
||||||
|
"78.50": "#FF2600",
|
||||||
|
"82.50": "#FF2600",
|
||||||
|
"86.50": "#FF2600"
|
||||||
|
},
|
||||||
|
"ringBottom": {
|
||||||
|
"6.00": "#AD1602",
|
||||||
|
"9.00": "#AD1602",
|
||||||
|
"12.00": "#B21902",
|
||||||
|
"15.00": "#B32301",
|
||||||
|
"18.00": "#B43102",
|
||||||
|
"21.00": "#BC3C01",
|
||||||
|
"24.00": "#C54801",
|
||||||
|
"27.00": "#C45501",
|
||||||
|
"30.00": "#CE6100",
|
||||||
|
"33.01": "#CC6D00",
|
||||||
|
"36.01": "#D57900",
|
||||||
|
"39.01": "#D58401",
|
||||||
|
"42.01": "#DD9201",
|
||||||
|
"45.01": "#DE9D02",
|
||||||
|
"48.01": "#525551",
|
||||||
|
"51.01": "#525551",
|
||||||
|
"54.01": "#525551",
|
||||||
|
"57.01": "#525551",
|
||||||
|
"60.01": "#525551",
|
||||||
|
"63.01": "#525551",
|
||||||
|
"66.00": "#525551"
|
||||||
|
},
|
||||||
|
"name": "Ember"
|
||||||
|
},
|
||||||
|
"02": {
|
||||||
|
"anchorDot": "#0F3C82",
|
||||||
|
"bandFill": "#525551",
|
||||||
|
"bandText": "#FFFFFF",
|
||||||
|
"gHours": [
|
||||||
|
"#6F6F6F",
|
||||||
|
"#2F312F"
|
||||||
|
],
|
||||||
|
"gMinutes": [
|
||||||
|
"#567D01",
|
||||||
|
"#036E6F"
|
||||||
|
],
|
||||||
|
"colon": "#525551",
|
||||||
|
"battery-icon": "#ACAAAD",
|
||||||
|
"battery-value": "#FFFFFF",
|
||||||
|
"sunrise-icon": "#ACAAAD",
|
||||||
|
"sunrise-value": "#FFFFFF",
|
||||||
|
"weather-icon": "#ADABAE",
|
||||||
|
"weather-value": "#FFFFFF",
|
||||||
|
"dist-value": "#FFFFFF",
|
||||||
|
"dist-label": "#ADABAE",
|
||||||
|
"steps-value": "#FFFFFF",
|
||||||
|
"steps-label": "#ADABAE",
|
||||||
|
"ringTop": {
|
||||||
|
"6.50": "#A4F909",
|
||||||
|
"10.50": "#A4F918",
|
||||||
|
"14.50": "#9DF91E",
|
||||||
|
"18.50": "#9EFA31",
|
||||||
|
"22.50": "#95FA39",
|
||||||
|
"26.50": "#94F94A",
|
||||||
|
"30.50": "#8AFA52",
|
||||||
|
"34.50": "#8BFA61",
|
||||||
|
"38.50": "#84FA6A",
|
||||||
|
"42.50": "#85FA7C",
|
||||||
|
"46.50": "#7BFA82",
|
||||||
|
"50.50": "#7BFA94",
|
||||||
|
"54.50": "#73FB9B",
|
||||||
|
"58.50": "#74FBAB",
|
||||||
|
"62.50": "#6BFBB4",
|
||||||
|
"66.50": "#6AFCC5",
|
||||||
|
"70.50": "#63FCCD",
|
||||||
|
"74.50": "#62FCDE",
|
||||||
|
"78.50": "#5AFCE6",
|
||||||
|
"82.50": "#59FDF6",
|
||||||
|
"86.50": "#52FDFF"
|
||||||
|
},
|
||||||
|
"ringBottom": {
|
||||||
|
"6.00": "#52FDFF",
|
||||||
|
"9.00": "#59FCF6",
|
||||||
|
"12.00": "#5AFCE6",
|
||||||
|
"15.00": "#62FCDE",
|
||||||
|
"18.00": "#62FCCD",
|
||||||
|
"21.00": "#6AFCC5",
|
||||||
|
"24.00": "#6BFBB4",
|
||||||
|
"27.00": "#74FBAB",
|
||||||
|
"30.00": "#73FB9B",
|
||||||
|
"33.01": "#7BFA94",
|
||||||
|
"36.01": "#7BFA83",
|
||||||
|
"39.01": "#85FA7C",
|
||||||
|
"42.01": "#84FA6A",
|
||||||
|
"45.01": "#8BFA61",
|
||||||
|
"48.01": "#525551",
|
||||||
|
"51.01": "#525551",
|
||||||
|
"54.01": "#525551",
|
||||||
|
"57.01": "#525551",
|
||||||
|
"60.01": "#525551",
|
||||||
|
"63.01": "#525551",
|
||||||
|
"66.00": "#525551"
|
||||||
|
},
|
||||||
|
"name": "Aurora"
|
||||||
|
},
|
||||||
|
"03": {
|
||||||
|
"anchorDot": "#62441E",
|
||||||
|
"bandFill": "#392810",
|
||||||
|
"bandText": "#FFFFFF",
|
||||||
|
"gHours": [
|
||||||
|
"#6F6F6F",
|
||||||
|
"#2F312F"
|
||||||
|
],
|
||||||
|
"gMinutes": [
|
||||||
|
"#78674B",
|
||||||
|
"#4D3620"
|
||||||
|
],
|
||||||
|
"colon": "#525551",
|
||||||
|
"battery-icon": "#B67E4B",
|
||||||
|
"battery-value": "#F0CF98",
|
||||||
|
"sunrise-icon": "#B57D4A",
|
||||||
|
"sunrise-value": "#F0CF97",
|
||||||
|
"weather-icon": "#B57E4B",
|
||||||
|
"weather-value": "#F0CF97",
|
||||||
|
"dist-value": "#F0CF97",
|
||||||
|
"dist-label": "#B57E4C",
|
||||||
|
"steps-value": "#F0CF97",
|
||||||
|
"steps-label": "#B57E4C",
|
||||||
|
"ringTop": {
|
||||||
|
"6.50": "#B3814A",
|
||||||
|
"10.50": "#B5854B",
|
||||||
|
"14.50": "#B48951",
|
||||||
|
"18.50": "#BD8E52",
|
||||||
|
"22.50": "#BD915B",
|
||||||
|
"26.50": "#C5955B",
|
||||||
|
"30.50": "#C59962",
|
||||||
|
"34.50": "#C59D62",
|
||||||
|
"38.50": "#CD9D6B",
|
||||||
|
"42.50": "#CCA169",
|
||||||
|
"46.50": "#CCA573",
|
||||||
|
"50.50": "#D6AA73",
|
||||||
|
"54.50": "#D5AE7B",
|
||||||
|
"58.50": "#D5B37A",
|
||||||
|
"62.50": "#DEB682",
|
||||||
|
"66.50": "#DEBA83",
|
||||||
|
"70.50": "#DEBD8B",
|
||||||
|
"74.50": "#E5C28B",
|
||||||
|
"78.50": "#E6C693",
|
||||||
|
"82.50": "#E6CA94",
|
||||||
|
"86.50": "#EFCE95"
|
||||||
|
},
|
||||||
|
"ringBottom": {
|
||||||
|
"6.00": "#B47D4A",
|
||||||
|
"9.00": "#B3814A",
|
||||||
|
"12.00": "#B4844A",
|
||||||
|
"15.00": "#B48951",
|
||||||
|
"18.00": "#BD8E52",
|
||||||
|
"21.00": "#BD915B",
|
||||||
|
"24.00": "#C5955B",
|
||||||
|
"27.00": "#C49962",
|
||||||
|
"30.00": "#C59962",
|
||||||
|
"33.01": "#CD9D6A",
|
||||||
|
"36.01": "#CDA169",
|
||||||
|
"39.01": "#CCA572",
|
||||||
|
"42.01": "#D6AA73",
|
||||||
|
"45.01": "#D5AE7B",
|
||||||
|
"48.01": "#392810",
|
||||||
|
"51.01": "#392710",
|
||||||
|
"54.01": "#392810",
|
||||||
|
"57.01": "#392810",
|
||||||
|
"60.01": "#392810",
|
||||||
|
"63.01": "#392810",
|
||||||
|
"66.00": "#392810"
|
||||||
|
},
|
||||||
|
"name": "Brass"
|
||||||
|
},
|
||||||
|
"04": {
|
||||||
|
"anchorDot": "#53AAFF",
|
||||||
|
"bandFill": "#53AAFF",
|
||||||
|
"bandText": "#000000",
|
||||||
|
"gHours": [
|
||||||
|
"#6F6F6F",
|
||||||
|
"#2F312F"
|
||||||
|
],
|
||||||
|
"gMinutes": [
|
||||||
|
"#29557F",
|
||||||
|
"#848301"
|
||||||
|
],
|
||||||
|
"colon": "#525551",
|
||||||
|
"battery-icon": "#ACAAAD",
|
||||||
|
"battery-value": "#55ABFF",
|
||||||
|
"sunrise-icon": "#ACAAAD",
|
||||||
|
"sunrise-value": "#55ABFF",
|
||||||
|
"weather-icon": "#ADABAE",
|
||||||
|
"weather-value": "#55ABFF",
|
||||||
|
"dist-value": "#55ABFF",
|
||||||
|
"dist-label": "#ADABAE",
|
||||||
|
"steps-value": "#55ABFF",
|
||||||
|
"steps-label": "#ADABAE",
|
||||||
|
"ringTop": {
|
||||||
|
"6.50": "#5AAEEE",
|
||||||
|
"10.50": "#62B2E5",
|
||||||
|
"14.50": "#6AB6DF",
|
||||||
|
"18.50": "#72BACC",
|
||||||
|
"22.50": "#7CBEC5",
|
||||||
|
"26.50": "#83C1B4",
|
||||||
|
"30.50": "#8CC6AC",
|
||||||
|
"34.50": "#94C99C",
|
||||||
|
"38.50": "#9DCE94",
|
||||||
|
"42.50": "#A3D283",
|
||||||
|
"46.50": "#ACD67A",
|
||||||
|
"50.50": "#B5DA6A",
|
||||||
|
"54.50": "#BEDD62",
|
||||||
|
"58.50": "#C5E252",
|
||||||
|
"62.50": "#CDE64B",
|
||||||
|
"66.50": "#D5EA3A",
|
||||||
|
"70.50": "#DEED30",
|
||||||
|
"74.50": "#E7F221",
|
||||||
|
"78.50": "#EDF617",
|
||||||
|
"82.50": "#F6FA08",
|
||||||
|
"86.50": "#FFFB01"
|
||||||
|
},
|
||||||
|
"ringBottom": {
|
||||||
|
"6.00": "#FFFB01",
|
||||||
|
"9.00": "#F6FA08",
|
||||||
|
"12.00": "#EDF617",
|
||||||
|
"15.00": "#E7F221",
|
||||||
|
"18.00": "#DEED30",
|
||||||
|
"21.00": "#D5EA38",
|
||||||
|
"24.00": "#CDE64B",
|
||||||
|
"27.00": "#C5E252",
|
||||||
|
"30.00": "#BEDE62",
|
||||||
|
"33.01": "#B5DA6A",
|
||||||
|
"36.01": "#ACD67A",
|
||||||
|
"39.01": "#A3D182",
|
||||||
|
"42.01": "#9CCE94",
|
||||||
|
"45.01": "#94C99C",
|
||||||
|
"48.01": "#525551",
|
||||||
|
"51.01": "#525551",
|
||||||
|
"54.01": "#525551",
|
||||||
|
"57.01": "#525551",
|
||||||
|
"60.01": "#525551",
|
||||||
|
"63.01": "#525551",
|
||||||
|
"66.00": "#525551"
|
||||||
|
},
|
||||||
|
"name": "Voltage"
|
||||||
|
},
|
||||||
|
"05": {
|
||||||
|
"anchorDot": "#FE7900",
|
||||||
|
"bandFill": "#52FDFF",
|
||||||
|
"bandText": "#000000",
|
||||||
|
"gHours": [
|
||||||
|
"#6F6F6F",
|
||||||
|
"#2F312F"
|
||||||
|
],
|
||||||
|
"gMinutes": [
|
||||||
|
"#7F3C01",
|
||||||
|
"#2B8384"
|
||||||
|
],
|
||||||
|
"colon": "#525551",
|
||||||
|
"battery-icon": "#FEAA03",
|
||||||
|
"battery-value": "#FFFFFF",
|
||||||
|
"sunrise-icon": "#FEAA01",
|
||||||
|
"sunrise-value": "#FFFFFF",
|
||||||
|
"weather-icon": "#FEAA05",
|
||||||
|
"weather-value": "#FFFFFF",
|
||||||
|
"dist-value": "#FFFFFF",
|
||||||
|
"dist-label": "#FEAB05",
|
||||||
|
"steps-value": "#FFFFFF",
|
||||||
|
"steps-label": "#FEAA05",
|
||||||
|
"ringTop": {
|
||||||
|
"6.50": "#F57D07",
|
||||||
|
"10.50": "#ED8618",
|
||||||
|
"14.50": "#E68D20",
|
||||||
|
"18.50": "#DE9130",
|
||||||
|
"22.50": "#D49939",
|
||||||
|
"26.50": "#CC9D49",
|
||||||
|
"30.50": "#C5A552",
|
||||||
|
"34.50": "#BCAA61",
|
||||||
|
"38.50": "#B3B26A",
|
||||||
|
"42.50": "#ADB67C",
|
||||||
|
"46.50": "#A4BE83",
|
||||||
|
"50.50": "#9CC694",
|
||||||
|
"54.50": "#94CA9C",
|
||||||
|
"58.50": "#8CD2AB",
|
||||||
|
"62.50": "#83D6B5",
|
||||||
|
"66.50": "#7BDEC5",
|
||||||
|
"70.50": "#74E6CE",
|
||||||
|
"74.50": "#6AEADF",
|
||||||
|
"78.50": "#63F2E7",
|
||||||
|
"82.50": "#5BF6F6",
|
||||||
|
"86.50": "#52FDFF"
|
||||||
|
},
|
||||||
|
"ringBottom": {
|
||||||
|
"6.00": "#FE7900",
|
||||||
|
"9.00": "#F57C07",
|
||||||
|
"12.00": "#ED8618",
|
||||||
|
"15.00": "#E68920",
|
||||||
|
"18.00": "#DE9130",
|
||||||
|
"21.00": "#D49939",
|
||||||
|
"24.00": "#CD9D49",
|
||||||
|
"27.00": "#C5A552",
|
||||||
|
"30.00": "#BCAA61",
|
||||||
|
"33.01": "#B3B26A",
|
||||||
|
"36.01": "#ADB67C",
|
||||||
|
"39.01": "#A4BE83",
|
||||||
|
"42.01": "#9CC694",
|
||||||
|
"45.01": "#94C99C",
|
||||||
|
"48.01": "#525551",
|
||||||
|
"51.01": "#525551",
|
||||||
|
"54.01": "#525551",
|
||||||
|
"57.01": "#525551",
|
||||||
|
"60.01": "#525551",
|
||||||
|
"63.01": "#525551",
|
||||||
|
"66.00": "#525551"
|
||||||
|
},
|
||||||
|
"name": "Reef"
|
||||||
|
},
|
||||||
|
"06": {
|
||||||
|
"anchorDot": "#F58909",
|
||||||
|
"bandFill": "#525551",
|
||||||
|
"bandText": "#000000",
|
||||||
|
"gHours": [
|
||||||
|
"#85961E",
|
||||||
|
"#556D01"
|
||||||
|
],
|
||||||
|
"gMinutes": [
|
||||||
|
"#769600",
|
||||||
|
"#556D01"
|
||||||
|
],
|
||||||
|
"colon": "#525551",
|
||||||
|
"battery-icon": "#C6FA03",
|
||||||
|
"battery-value": "#FFFFFF",
|
||||||
|
"sunrise-icon": "#C5FA01",
|
||||||
|
"sunrise-value": "#FFFFFF",
|
||||||
|
"weather-icon": "#C7FA03",
|
||||||
|
"weather-value": "#FFFFFF",
|
||||||
|
"dist-value": "#FFFFFF",
|
||||||
|
"dist-label": "#C6FA04",
|
||||||
|
"steps-value": "#FFFFFF",
|
||||||
|
"steps-label": "#C7FA03",
|
||||||
|
"ringTop": {
|
||||||
|
"6.50": "#9CCA0B",
|
||||||
|
"10.50": "#9CCF08",
|
||||||
|
"14.50": "#A4CE08",
|
||||||
|
"18.50": "#A4D208",
|
||||||
|
"22.50": "#A4D60A",
|
||||||
|
"26.50": "#A4D60A",
|
||||||
|
"30.50": "#ABDA06",
|
||||||
|
"34.50": "#ADDA00",
|
||||||
|
"38.50": "#ADDE02",
|
||||||
|
"42.50": "#ADDE02",
|
||||||
|
"46.50": "#B4E200",
|
||||||
|
"50.50": "#B4E600",
|
||||||
|
"54.50": "#B4E600",
|
||||||
|
"58.50": "#B4EA00",
|
||||||
|
"62.50": "#B4EA00",
|
||||||
|
"66.50": "#BDEE00",
|
||||||
|
"70.50": "#BDF101",
|
||||||
|
"74.50": "#BDF101",
|
||||||
|
"78.50": "#BEF600",
|
||||||
|
"82.50": "#C6F600",
|
||||||
|
"86.50": "#C5FA00"
|
||||||
|
},
|
||||||
|
"ringBottom": {
|
||||||
|
"6.00": "#C5FA00",
|
||||||
|
"9.00": "#C6F600",
|
||||||
|
"12.00": "#BEF600",
|
||||||
|
"15.00": "#BDF101",
|
||||||
|
"18.00": "#BDF101",
|
||||||
|
"21.00": "#BDEE00",
|
||||||
|
"24.00": "#B4EA00",
|
||||||
|
"27.00": "#B4EA00",
|
||||||
|
"30.00": "#B4E600",
|
||||||
|
"33.01": "#B4E600",
|
||||||
|
"36.01": "#B4E100",
|
||||||
|
"39.01": "#ACDE01",
|
||||||
|
"42.01": "#ADDE02",
|
||||||
|
"45.01": "#ADD900",
|
||||||
|
"48.01": "#525551",
|
||||||
|
"51.01": "#525551",
|
||||||
|
"54.01": "#525551",
|
||||||
|
"57.01": "#525551",
|
||||||
|
"60.01": "#525551",
|
||||||
|
"63.01": "#525551",
|
||||||
|
"66.00": "#525551"
|
||||||
|
},
|
||||||
|
"name": "Acid"
|
||||||
|
},
|
||||||
|
"07": {
|
||||||
|
"anchorDot": "#F58909",
|
||||||
|
"bandFill": "#525551",
|
||||||
|
"bandText": "#000000",
|
||||||
|
"gHours": [
|
||||||
|
"#935205",
|
||||||
|
"#844103"
|
||||||
|
],
|
||||||
|
"gMinutes": [
|
||||||
|
"#935205",
|
||||||
|
"#762E05"
|
||||||
|
],
|
||||||
|
"colon": "#525551",
|
||||||
|
"battery-icon": "#34C294",
|
||||||
|
"battery-value": "#FFFFFF",
|
||||||
|
"sunrise-icon": "#32C294",
|
||||||
|
"sunrise-value": "#FFFFFF",
|
||||||
|
"weather-icon": "#34C294",
|
||||||
|
"weather-value": "#FFFFFF",
|
||||||
|
"dist-value": "#FFFFFF",
|
||||||
|
"dist-label": "#34C294",
|
||||||
|
"steps-value": "#FFFFFF",
|
||||||
|
"steps-label": "#34C294",
|
||||||
|
"ringTop": {
|
||||||
|
"6.50": "#EE8910",
|
||||||
|
"10.50": "#DD8D17",
|
||||||
|
"14.50": "#D49117",
|
||||||
|
"18.50": "#CD911F",
|
||||||
|
"22.50": "#C59528",
|
||||||
|
"26.50": "#BE9931",
|
||||||
|
"30.50": "#B49D3A",
|
||||||
|
"34.50": "#AB9D39",
|
||||||
|
"38.50": "#A3A141",
|
||||||
|
"42.50": "#94A54A",
|
||||||
|
"46.50": "#8BA552",
|
||||||
|
"50.50": "#83AA5A",
|
||||||
|
"54.50": "#7BAA5A",
|
||||||
|
"58.50": "#73AE63",
|
||||||
|
"62.50": "#69B269",
|
||||||
|
"66.50": "#63B273",
|
||||||
|
"70.50": "#52B67C",
|
||||||
|
"74.50": "#4BBA82",
|
||||||
|
"78.50": "#42B983",
|
||||||
|
"82.50": "#39BD8B",
|
||||||
|
"86.50": "#31C293"
|
||||||
|
},
|
||||||
|
"ringBottom": {
|
||||||
|
"6.00": "#CD5008",
|
||||||
|
"9.00": "#C5550E",
|
||||||
|
"12.00": "#BD5A1A",
|
||||||
|
"15.00": "#B46118",
|
||||||
|
"18.00": "#AC651F",
|
||||||
|
"21.00": "#A46A29",
|
||||||
|
"24.00": "#9B712F",
|
||||||
|
"27.00": "#9C763A",
|
||||||
|
"30.00": "#94793A",
|
||||||
|
"33.01": "#8B8241",
|
||||||
|
"36.01": "#83854A",
|
||||||
|
"39.01": "#7A8952",
|
||||||
|
"42.01": "#74915A",
|
||||||
|
"45.01": "#6A955A",
|
||||||
|
"48.01": "#525551",
|
||||||
|
"51.01": "#525551",
|
||||||
|
"54.01": "#525551",
|
||||||
|
"57.01": "#525551",
|
||||||
|
"60.01": "#525551",
|
||||||
|
"63.01": "#525551",
|
||||||
|
"66.00": "#525551"
|
||||||
|
},
|
||||||
|
"name": "Kelp"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# 纯标准库生成表盘运行效果 SVG(复用 Fenix8V3View.mc 的坐标与主题色)
|
|
||||||
import math
|
|
||||||
|
|
||||||
THEMES = {
|
|
||||||
"01":{"name":"Ember","ringTop":[[8,0xFFF203],[15,0xFDDB0D],[25,0xFFC203],[35,0xFFA00A],[45,0xFF8705],[55,0xFF610C],[65,0xFF4904],[75,0xFF2602],[86,0xFF2604]],
|
|
||||||
"ringBottom":[[7,0xAE1706],[14,0xB32504],[21,0xC03B03],[28,0xCD6206],[35,0xD57805],[42,0xDE9208],[47,0xC98D00]],
|
|
||||||
"tickOff":0x545655,"anchorDot":0x525551,"bandFill":0xFEAA00,"bandText":0x000002,"accent":0xFCA901,"textPrimary":0xFAFAFA,"colon":0x525551,"hours":0xFBFBFB,"minutes":0xFCA901},
|
|
||||||
"02":{"name":"Aurora","ringTop":[[8,0xA5FA11],[15,0x9EFA23],[25,0x97F84B],[35,0x8CF965],[45,0x89F87F],[55,0x74FB9F],[65,0x6DFCC6],[75,0x64FCDF],[86,0x57FDFF]],
|
|
||||||
"ringBottom":[[7,0x55FDFF],[14,0x64FCDF],[21,0x6FFCC6],[28,0x74FBAE],[35,0x7EFA85],[42,0x88F96D],[47,0x81E654]],
|
|
||||||
"tickOff":0x545655,"anchorDot":0x0F3C82,"bandFill":0x525551,"bandText":0xFFFFFF,"accent":0xABA9AC,"textPrimary":0xFAFAFA,"colon":0x525551,"hours":0xFBFBFB,"minutes":0xAAF701},
|
|
||||||
"03":{"name":"Brass","ringTop":[[8,0xB4814F],[15,0xB88954],[25,0xC5975C],[35,0xC59E65],[45,0xCBA775],[55,0xD7AE7F],[65,0xDEBC84],[75,0xE7C48D],[86,0xF0D097]],
|
|
||||||
"ringBottom":[[7,0xB97E4A],[14,0xB68954],[21,0xBD925E],[28,0xC69B67],[35,0xCEA26C],[42,0xD9AB74],[47,0xC59E70]],
|
|
||||||
"tickOff":0x3A2815,"anchorDot":0x62441E,"bandFill":0x392810,"bandText":0xFFFFFF,"accent":0xB37C4A,"textPrimary":0xEBCB93,"colon":0x525551,"hours":0xFBFBFB,"minutes":0xECCC93},
|
|
||||||
"04":{"name":"Voltage","ringTop":[[8,0x5CAFF0],[15,0x6EB6E0],[25,0x7FBEC5],[35,0x96CA9D],[45,0xAAD87F],[55,0xBFDF66],[65,0xD6EA40],[75,0xE8F227],[86,0xFFFB0A]],
|
|
||||||
"ringBottom":[[7,0xF7FA11],[14,0xE9F225],[21,0xD6EB3F],[28,0xC0DE65],[35,0xADD880],[42,0xA0CF98],[47,0x88B98E]],
|
|
||||||
"tickOff":0x545655,"anchorDot":0x53AAFF,"bandFill":0x53AAFF,"bandText":0x000000,"accent":0xABA9AC,"textPrimary":0x51A7FA,"colon":0x525551,"hours":0xFBFBFB,"minutes":0x52A8FD},
|
|
||||||
"05":{"name":"Reef","ringTop":[[8,0xF87E0D],[15,0xE78E25],[25,0xCE9B4D],[35,0xBDAC65],[45,0xA5BE88],[55,0x95CA9E],[65,0x7EDEC7],[75,0x6DEBE1],[86,0x57FDFF]],
|
|
||||||
"ringBottom":[[7,0xFF7905],[14,0xE78B29],[21,0xD59C3E],[28,0xBEAC63],[35,0xADB77F],[42,0xA0C796],[47,0x88B98E]],
|
|
||||||
"tickOff":0x545655,"anchorDot":0xFE7900,"bandFill":0x52FDFF,"bandText":0x000000,"accent":0xFCA901,"textPrimary":0xFAFAFA,"colon":0x525551,"hours":0xFBFBFB,"minutes":0xFC7801},
|
|
||||||
"06":{"name":"Acid","ringTop":[[8,0x9CCC14],[15,0xA9CE0C],[25,0xA4D610],[35,0xACDB08],[45,0xB4E107],[55,0xB5E603],[65,0xBFEE04],[75,0xBDF108],[86,0xC7F808]],
|
|
||||||
"ringBottom":[[7,0xC7FA02],[14,0xBEF20A],[21,0xC0ED04],[28,0xB4E905],[35,0xB4E107],[42,0xADDD0C],[47,0x9BC603]],
|
|
||||||
"tickOff":0x545655,"anchorDot":0xF58909,"bandFill":0x525551,"bandText":0x000000,"accent":0xC4F801,"textPrimary":0xFAFAFA,"colon":0x525551,"hours":0xDBF731,"minutes":0xC4F900},
|
|
||||||
"07":{"name":"Kelp","ringTop":[[8,0xEF8917],[15,0xD8901C],[25,0xBD9A34],[35,0xAD9E3C],[45,0x96A54E],[55,0x7DAE5D],[65,0x61B474],[75,0x4EBB84],[86,0x36C294]],
|
|
||||||
"ringBottom":[[7,0xCE520F],[14,0xB1631D],[21,0xA26A2F],[28,0x9D763E],[35,0x85874B],[42,0x76915D],[47,0x5E8651]],
|
|
||||||
"tickOff":0x545655,"anchorDot":0xF58909,"bandFill":0x525551,"bandText":0x000000,"accent":0x31C192,"textPrimary":0xFAFAFA,"colon":0x525551,"hours":0xF28708,"minutes":0xF4880A},
|
|
||||||
}
|
|
||||||
ORDER=["01","02","03","04","05","06","07"]
|
|
||||||
TOP=[a for a in range(2,87,4)]; BOT=[a for a in range(6,67,3)]
|
|
||||||
DOW=["SUN","MON","TUE","WED","THU","FRI","SAT"]; MON=["JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"]
|
|
||||||
|
|
||||||
def hx(c): return "#%06x"%(c&0xffffff)
|
|
||||||
def lerp(c1,c2,f):
|
|
||||||
r1=(c1>>16)&255;g1=(c1>>8)&255;b1=c1&255
|
|
||||||
r2=(c2>>16)&255;g2=(c2>>8)&255;b2=c2&255
|
|
||||||
return ((round(r1+(r2-r1)*f))<<16)|((round(g1+(g2-g1)*f))<<8)|round(b1+(b2-b1)*f)
|
|
||||||
def lerp_stops(stops,t):
|
|
||||||
n=len(stops)
|
|
||||||
if not n: return 0x888888
|
|
||||||
if t<=stops[0][0]: return stops[0][1]
|
|
||||||
if t>=stops[-1][0]: return stops[-1][1]
|
|
||||||
for i in range(n-1):
|
|
||||||
a,b=stops[i],stops[i+1]
|
|
||||||
if a[0]<=t<=b[0]:
|
|
||||||
return lerp(a[1],b[1],(t-a[0])/(b[0]-a[0]))
|
|
||||||
return stops[-1][1]
|
|
||||||
|
|
||||||
def tick(cx,cy,rIn,rOut,w,ang,col,mirror):
|
|
||||||
eff=-ang if mirror else ang; rad=math.radians(eff); s=math.sin(rad); c=math.cos(rad)
|
|
||||||
return f'<line x1="{cx+s*rIn:.2f}" y1="{cy-c*rIn:.2f}" x2="{cx+s*rOut:.2f}" y2="{cy-c*rOut:.2f}" stroke="{hx(col)}" stroke-width="{w}" stroke-linecap="round"/>'
|
|
||||||
|
|
||||||
def ring(theme,pTR,pTL,pBR,pBL):
|
|
||||||
out=[]
|
|
||||||
rOut=249.5;rIn=231.5;off=theme["tickOff"];ts=theme["ringTop"];bs=theme["ringBottom"]
|
|
||||||
topW=13;botW=9
|
|
||||||
litTR=round(pTR*22);litTL=round(pTL*22);litBR=round(pBR*21);litBL=round(pBL*21)
|
|
||||||
# TR anchorOuter, mirror=false
|
|
||||||
for i,a in enumerate(TOP):
|
|
||||||
on=i>=(22-litTR); col=lerp_stops(ts,a) if on else off
|
|
||||||
out.append(tick(250,250,rIn,rOut,topW,a,col,False))
|
|
||||||
# TL anchorOuter, mirror=true
|
|
||||||
for i,a in enumerate(TOP):
|
|
||||||
on=i>=(22-litTL); col=lerp_stops(ts,a) if on else off
|
|
||||||
out.append(tick(250,250,rIn,rOut,topW,a,col,True))
|
|
||||||
# BL mirror=true
|
|
||||||
for i,a in enumerate(BOT):
|
|
||||||
col=lerp_stops(bs,a) if i<litBL else off
|
|
||||||
out.append(tick(250,250,rIn,rOut,botW,180-a,col,True))
|
|
||||||
# BR mirror=false
|
|
||||||
for i,a in enumerate(BOT):
|
|
||||||
col=lerp_stops(bs,a) if i<litBR else off
|
|
||||||
out.append(tick(250,250,rIn,rOut,botW,180-a,col,False))
|
|
||||||
# anchor dots
|
|
||||||
dc=theme["anchorDot"];dr=9.5;drad=240.5
|
|
||||||
for da in [0,93.3,180,266.7]:
|
|
||||||
rad=math.radians(da)
|
|
||||||
out.append(f'<circle cx="{250+math.sin(rad)*drad:.2f}" cy="{250-math.cos(rad)*drad:.2f}" r="{dr}" fill="{hx(dc)}"/>')
|
|
||||||
return "".join(out)
|
|
||||||
|
|
||||||
def batt(theme,pct):
|
|
||||||
y=53.5;gw=30;gh=16;gx=250-52;gy=y-gh/2;ac=theme["accent"];tp=theme["textPrimary"]
|
|
||||||
fill_w=(gw-4)*pct/100
|
|
||||||
return (f'<rect x="{gx}" y="{gy}" width="{gw}" height="{gh}" fill="none" stroke="{hx(ac)}" stroke-width="2"/>'
|
|
||||||
f'<rect x="{gx+gw}" y="{gy+gh*0.28}" width="3" height="{gh*0.44}" fill="{hx(ac)}"/>'
|
|
||||||
f'<rect x="{gx+2}" y="{gy+2}" width="{fill_w:.2f}" height="{gh-4}" fill="{hx(ac)}"/>'
|
|
||||||
f'<text x="{250+16}" y="{y}" fill="{hx(tp)}" font-size="16" font-family="sans-serif" text-anchor="start" dominant-baseline="central">{round(pct)}%</text>')
|
|
||||||
|
|
||||||
def complic_icon(x,y,r,col,t):
|
|
||||||
c=hx(col)
|
|
||||||
if t==0: # sun
|
|
||||||
s=f'<circle cx="{x}" cy="{y}" r="{r*0.45}" fill="{c}"/>'
|
|
||||||
for i in range(8):
|
|
||||||
rad=math.radians(i*45)
|
|
||||||
s+=f'<line x1="{x+math.sin(rad)*r*0.6:.2f}" y1="{y-math.cos(rad)*r*0.6:.2f}" x2="{x+math.sin(rad)*r:.2f}" y2="{y-math.cos(rad)*r:.2f}" stroke="{c}" stroke-width="2"/>'
|
|
||||||
return s
|
|
||||||
if t==1: # flame
|
|
||||||
return f'<polygon points="{x},{y-r} {x+r*0.8:.2f},{y+r*0.6:.2f} {x-r*0.8:.2f},{y+r*0.6:.2f}" fill="{c}"/>'
|
|
||||||
if t==2: # steps
|
|
||||||
return (f'<line x1="{x-r*0.5:.2f}" y1="{y-r*0.3:.2f}" x2="{x+r*0.5:.2f}" y2="{y-r*0.3:.2f}" stroke="{c}" stroke-width="2"/>'
|
|
||||||
f'<line x1="{x-r*0.3:.2f}" y1="{y}" x2="{x+r*0.6:.2f}" y2="{y}" stroke="{c}" stroke-width="2"/>')
|
|
||||||
if t==3: # dot
|
|
||||||
return f'<circle cx="{x}" cy="{y}" r="{r*0.5}" fill="{c}"/>'
|
|
||||||
if t==4: # chevron
|
|
||||||
return f'<polygon points="{x},{y-r} {x+r*0.7:.2f},{y+r*0.2:.2f} {x-r*0.7:.2f},{y+r*0.2:.2f}" fill="{c}"/>'
|
|
||||||
return f'<rect x="{x-r*0.6:.2f}" y="{y-r*0.4:.2f}" width="{r*1.2:.2f}" height="{r*0.8:.2f}" fill="none" stroke="{c}" stroke-width="2"/>'
|
|
||||||
|
|
||||||
def top_comp(theme,t,isLeft,val):
|
|
||||||
sx=156 if isLeft else 343; iy=90; vy=130.5
|
|
||||||
return (f'<text x="{sx}" y="{vy}" fill="{hx(theme["textPrimary"])}" font-size="22" font-family="sans-serif" text-anchor="middle" dominant-baseline="central">{val}</text>'
|
|
||||||
+complic_icon(sx,iy,13,theme["accent"],t))
|
|
||||||
|
|
||||||
def bluetooth(x,y,r,col):
|
|
||||||
c=hx(col)
|
|
||||||
return (f'<path d="M{x},{y-r} L{x},{y+r} M{x},{y-r} L{x+r*0.7:.2f},{y-r*0.3:.2f} L{x},{y} '
|
|
||||||
f'L{x+r*0.7:.2f},{y+r*0.3:.2f} L{x},{y+r}" fill="none" stroke="{c}" stroke-width="3" stroke-linejoin="round"/>')
|
|
||||||
|
|
||||||
def date_band(theme,gi):
|
|
||||||
by=286.5;bh=44;yc=308.5;bf=theme["bandFill"];bt=theme["bandText"]
|
|
||||||
dow=DOW[gi["dow"]-1]; md=MON[gi["m"]-1]+" "+str(gi["day"])
|
|
||||||
return (f'<rect x="0" y="{by}" width="500" height="{bh}" fill="{hx(bf)}"/>'
|
|
||||||
+bluetooth(36.5,yc,10,bt)
|
|
||||||
+f'<text x="177.5" y="{yc}" fill="{hx(bt)}" font-size="16" font-family="sans-serif" text-anchor="middle" dominant-baseline="central">{dow}</text>'
|
|
||||||
+f'<text x="297.5" y="{yc}" fill="{hx(bt)}" font-size="16" font-family="sans-serif" text-anchor="middle" dominant-baseline="central">{md}</text>'
|
|
||||||
+f'<text x="465" y="{yc}" fill="{hx(bt)}" font-size="16" font-family="sans-serif" text-anchor="end" dominant-baseline="central">{"PM" if gi["h"]>=12 else "AM"}</text>')
|
|
||||||
|
|
||||||
def time(theme,gi):
|
|
||||||
h=gi["h"]; h=12 if h==0 else (h-12 if h>12 else h)
|
|
||||||
hs=str(h); ms="%02d"%gi["min"]; ty=220
|
|
||||||
return (f'<text x="211" y="{ty}" fill="{hx(theme["hours"])}" font-size="66" font-weight="700" font-family="sans-serif" text-anchor="end" dominant-baseline="central">{hs}</text>'
|
|
||||||
f'<text x="272" y="{ty}" fill="{hx(theme["minutes"])}" font-size="66" font-weight="700" font-family="sans-serif" text-anchor="start" dominant-baseline="central">{ms}</text>'
|
|
||||||
f'<rect x="238" y="184" width="24" height="20" fill="{hx(theme["colon"])}"/>'
|
|
||||||
f'<rect x="238" y="235" width="24" height="20" fill="{hx(theme["colon"])}"/>')
|
|
||||||
|
|
||||||
def bottom(theme,dist,steps):
|
|
||||||
vy=366;ly=410.5;lx=234;rx=266;tp=theme["textPrimary"];ac=theme["accent"]
|
|
||||||
return (f'<text x="{lx}" y="{vy}" fill="{hx(tp)}" font-size="22" font-family="sans-serif" text-anchor="end" dominant-baseline="central">{dist}</text>'
|
|
||||||
f'<text x="{lx}" y="{ly}" fill="{hx(ac)}" font-size="16" font-family="sans-serif" text-anchor="end" dominant-baseline="central">DST</text>'
|
|
||||||
f'<text x="{rx}" y="{vy}" fill="{hx(tp)}" font-size="22" font-family="sans-serif" text-anchor="start" dominant-baseline="central">{steps}</text>'
|
|
||||||
f'<text x="{rx}" y="{ly}" fill="{hx(ac)}" font-size="16" font-family="sans-serif" text-anchor="start" dominant-baseline="central">STEP</text>')
|
|
||||||
|
|
||||||
def face(theme_id,m,battery,temp,gi):
|
|
||||||
th=THEMES[theme_id]
|
|
||||||
p=[m[0],m[1],m[2],m[3],m[4]]
|
|
||||||
idxTR,idxTL,idxBR,idxBL=0,1,4,3
|
|
||||||
s=[]
|
|
||||||
s.append(f'<rect x="0" y="0" width="500" height="500" fill="#000"/>')
|
|
||||||
s.append(ring(th,p[idxTR],p[idxTL],p[idxBR],p[idxBL]))
|
|
||||||
s.append(batt(th,battery))
|
|
||||||
s.append(top_comp(th,1,True,f'{round(m[1]*2000):,}')) # 左上 卡路里
|
|
||||||
s.append(top_comp(th,0,False,f'{temp}\u00b0')) # 右上 天气
|
|
||||||
s.append(time(th,gi))
|
|
||||||
s.append(date_band(th,gi))
|
|
||||||
s.append(bottom(th,f'{m[2]*10:.1f}',f'{round(m[0]*10000):,}'))
|
|
||||||
return "".join(s)
|
|
||||||
|
|
||||||
# 七主题合成图:单排行,每格 230px,圆形裁切
|
|
||||||
m=[0.84,0.64,0.65,0.80,0.40]; battery=78; temp=23
|
|
||||||
gi={"h":10,"min":8,"day":9,"m":9,"dow":3}
|
|
||||||
cell=230; gap=24; n=len(ORDER); W=n*cell+(n+1)*gap; H=cell+70
|
|
||||||
parts=[f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">']
|
|
||||||
parts.append(f'<rect width="{W}" height="{H}" fill="#0a0a0a"/>')
|
|
||||||
for i,tid in enumerate(ORDER):
|
|
||||||
ox=gap+i*(cell+gap); oy=24
|
|
||||||
sc=cell/500.0
|
|
||||||
parts.append(f'<defs><clipPath id="c{i}"><circle cx="{ox+cell/2}" cy="{oy+cell/2}" r="{cell/2-2}"/></clipPath></defs>')
|
|
||||||
parts.append(f'<g clip-path="url(#c{i})"><g transform="translate({ox},{oy}) scale({sc:.4f})">'+face(tid,m,battery,temp,gi)+'</g></g>')
|
|
||||||
parts.append(f'<text x="{ox+cell/2}" y="{oy+cell+22}" fill="#cfccc4" font-size="15" font-family="sans-serif" text-anchor="middle">{i+1} · {THEMES[tid]["name"]}</text>')
|
|
||||||
parts.append('</svg>')
|
|
||||||
svg="\n".join(parts)
|
|
||||||
with open("/Users/ericwyuan/Desktop/Work/fenix8v3-watchface/preview-themes.svg","w") as f:
|
|
||||||
f.write(svg)
|
|
||||||
print("written preview-themes.svg", len(svg), "bytes; size", W,"x",H)
|
|
||||||
@@ -34,5 +34,14 @@
|
|||||||
<iq:language>eng</iq:language>
|
<iq:language>eng</iq:language>
|
||||||
</iq:languages>
|
</iq:languages>
|
||||||
|
|
||||||
|
<!-- Sensor is rejected for type="watchface" unless Background is also
|
||||||
|
requested, so altitude and pressure come from Activity.Info instead. -->
|
||||||
|
<iq:permissions>
|
||||||
|
<iq:uses-permission id="UserProfile"/>
|
||||||
|
<iq:uses-permission id="Communications"/>
|
||||||
|
<iq:uses-permission id="Background"/>
|
||||||
|
<iq:uses-permission id="SensorHistory"/>
|
||||||
|
</iq:permissions>
|
||||||
|
|
||||||
</iq:application>
|
</iq:application>
|
||||||
</iq:manifest>
|
</iq:manifest>
|
||||||
|
|||||||
@@ -1,9 +1,32 @@
|
|||||||
project.manifest = manifest.xml
|
project.manifest = manifest.xml
|
||||||
|
|
||||||
base.sourcePath = source
|
base.sourcePath = source
|
||||||
base.resourcePath = resources
|
|
||||||
base.excludeAnnotations = release
|
|
||||||
|
|
||||||
# Uncomment and adjust once you have SDK devices installed, e.g.:
|
# resources-chn holds the Simplified Chinese variant of every string; the
|
||||||
# fenix8.resourcePath = resources;resources-round-amoled
|
# -chn suffix is what tells monkeyc which language it belongs to.
|
||||||
# fenix7.resourcePath = resources;resources-round-mip
|
base.resourcePath = resources;resources-chn
|
||||||
|
|
||||||
|
# Two more resource trees per device, both generated by tools/gen_icons.py:
|
||||||
|
# resources-icons-<screen width> sunrise/sunset/weather rasterised from the
|
||||||
|
# design paths for that exact pixel grid
|
||||||
|
# resources-launcher-<size> launcher icon at the size the device asks for
|
||||||
|
|
||||||
|
enduro3.resourcePath = $(base.resourcePath);resources-icons-280;resources-launcher-40
|
||||||
|
epix2.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60
|
||||||
|
epix2pro47mm.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60
|
||||||
|
epix2pro51mm.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-60
|
||||||
|
fenix7.resourcePath = $(base.resourcePath);resources-icons-260;resources-launcher-40
|
||||||
|
fenix7s.resourcePath = $(base.resourcePath);resources-icons-240;resources-launcher-40
|
||||||
|
fenix7x.resourcePath = $(base.resourcePath);resources-icons-280;resources-launcher-40
|
||||||
|
fenix843mm.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60
|
||||||
|
fenix847mm.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-65
|
||||||
|
fenix8pro47mm.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-65
|
||||||
|
fenix8solar47mm.resourcePath = $(base.resourcePath);resources-icons-260;resources-launcher-40
|
||||||
|
fenix8solar51mm.resourcePath = $(base.resourcePath);resources-icons-280;resources-launcher-40
|
||||||
|
fr255.resourcePath = $(base.resourcePath);resources-icons-260;resources-launcher-40
|
||||||
|
fr265.resourcePath = $(base.resourcePath);resources-icons-416;resources-launcher-60
|
||||||
|
fr965.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-65
|
||||||
|
venu3.resourcePath = $(base.resourcePath);resources-icons-454;resources-launcher-70
|
||||||
|
vivoactive5.resourcePath = $(base.resourcePath);resources-icons-390;resources-launcher-56
|
||||||
|
|
||||||
|
base.excludeAnnotations = release
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 96 KiB |
350
preview.html
139
resources-chn/strings/fields.xml
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<strings>
|
||||||
|
<!-- Field names shown in the settings picker. -->
|
||||||
|
<string id="FldOff">关闭</string>
|
||||||
|
<string id="FldTime">时间</string>
|
||||||
|
<string id="FldUtcTime">UTC 时间</string>
|
||||||
|
<string id="FldAltTime1">第二时区 1</string>
|
||||||
|
<string id="FldAltTime2">第二时区 2</string>
|
||||||
|
<string id="FldDayMonthDay">星期 月 日</string>
|
||||||
|
<string id="FldDayDate">星期 日</string>
|
||||||
|
<string id="FldMonthDay">月 日</string>
|
||||||
|
<string id="FldDayOfMonth">日</string>
|
||||||
|
<string id="FldDayShort">星期简写</string>
|
||||||
|
<string id="FldDayFull">星期全称</string>
|
||||||
|
<string id="FldMonthName">月份</string>
|
||||||
|
<string id="FldMonthNum">月份数字</string>
|
||||||
|
<string id="FldMeridiem">上午/下午</string>
|
||||||
|
<string id="FldWeekIso">周数 (ISO)</string>
|
||||||
|
<string id="FldSeconds">秒</string>
|
||||||
|
<string id="FldSeconds0">秒 (前导零)</string>
|
||||||
|
<string id="FldWeekCal">周数</string>
|
||||||
|
<string id="FldSteps">步数</string>
|
||||||
|
<string id="FldStepsRound">步数 (取整)</string>
|
||||||
|
<string id="FldStepsToGo">剩余步数</string>
|
||||||
|
<string id="FldCalories">卡路里</string>
|
||||||
|
<string id="FldActiveCal">活动卡路里</string>
|
||||||
|
<string id="FldActiveMin">活动分钟</string>
|
||||||
|
<string id="FldActiveMod">中强度分钟</string>
|
||||||
|
<string id="FldActiveVig">高强度分钟</string>
|
||||||
|
<string id="FldActiveWeek">本周活动分钟</string>
|
||||||
|
<string id="FldDistance">距离</string>
|
||||||
|
<string id="FldFloorsUp">上楼层数</string>
|
||||||
|
<string id="FldFloorsDown">下楼层数</string>
|
||||||
|
<string id="FldMetersUp">爬升高度</string>
|
||||||
|
<string id="FldPushDist">推行距离</string>
|
||||||
|
<string id="FldPushes">推行次数</string>
|
||||||
|
<string id="FldStepsWeek">本周步数</string>
|
||||||
|
<string id="FldSteps7d">近 7 天步数</string>
|
||||||
|
<string id="FldCalWeek">本周卡路里</string>
|
||||||
|
<string id="FldCal7d">近 7 天卡路里</string>
|
||||||
|
<string id="FldDistWeek">本周距离</string>
|
||||||
|
<string id="FldDist7d">近 7 天距离</string>
|
||||||
|
<string id="FldFloorsWeek">本周楼层</string>
|
||||||
|
<string id="FldFloors7d">近 7 天楼层</string>
|
||||||
|
<string id="FldActiveMin7d">近 7 天活动分钟</string>
|
||||||
|
<string id="FldHeartRate">心率</string>
|
||||||
|
<string id="FldRestingHr">静息心率</string>
|
||||||
|
<string id="FldStress">压力指数</string>
|
||||||
|
<string id="FldPulseOx">血氧</string>
|
||||||
|
<string id="FldRespiration">呼吸频率</string>
|
||||||
|
<string id="FldBodyBattery">身体电量</string>
|
||||||
|
<string id="FldMoveBar">活动条</string>
|
||||||
|
<string id="FldMoveBarRev">活动条 (反向)</string>
|
||||||
|
<string id="FldWeight">体重</string>
|
||||||
|
<string id="FldBmi">BMI</string>
|
||||||
|
<string id="FldRecovery">恢复时间</string>
|
||||||
|
<string id="FldVo2Run">最大摄氧量 (跑)</string>
|
||||||
|
<string id="FldVo2Bike">最大摄氧量 (骑)</string>
|
||||||
|
<string id="FldBattery">电量</string>
|
||||||
|
<string id="FldBatteryDays">剩余天数</string>
|
||||||
|
<string id="FldBatteryBoth">电量 / 天数</string>
|
||||||
|
<string id="FldAlarms">闹钟</string>
|
||||||
|
<string id="FldNotifications">通知</string>
|
||||||
|
<string id="FldDoNotDisturb">勿扰模式</string>
|
||||||
|
<string id="FldBluetooth">蓝牙</string>
|
||||||
|
<string id="FldSolar">太阳能强度</string>
|
||||||
|
<string id="FldAltitude">海拔</string>
|
||||||
|
<string id="FldSeaPressure">海平面气压</string>
|
||||||
|
<string id="FldAmbPressure">环境气压</string>
|
||||||
|
<string id="FldSunrise">日出</string>
|
||||||
|
<string id="FldSunset">日落</string>
|
||||||
|
<string id="FldNextSun">下一太阳事件</string>
|
||||||
|
<string id="FldUntilSun">距太阳事件</string>
|
||||||
|
<string id="FldMoonAge">月龄</string>
|
||||||
|
<string id="FldMoonLit">月球照明</string>
|
||||||
|
<string id="FldCivilAm">民用晨光始</string>
|
||||||
|
<string id="FldNauticalAm">航海晨光始</string>
|
||||||
|
<string id="FldAstroAm">天文晨光始</string>
|
||||||
|
<string id="FldBlueAm">蓝调时刻 (晨)</string>
|
||||||
|
<string id="FldGoldenAm">黄金时刻 (晨)</string>
|
||||||
|
<string id="FldGoldenPm">黄金时刻 (昏)</string>
|
||||||
|
<string id="FldBluePm">蓝调时刻 (昏)</string>
|
||||||
|
<string id="FldCivilPm">民用昏影终</string>
|
||||||
|
<string id="FldNauticalPm">航海昏影终</string>
|
||||||
|
<string id="FldAstroPm">天文昏影终</string>
|
||||||
|
<string id="FldTemp">气温</string>
|
||||||
|
<string id="FldTempHiLo">最高/最低气温</string>
|
||||||
|
<string id="FldFeelsLike">体感温度</string>
|
||||||
|
<string id="FldTempHigh">最高气温</string>
|
||||||
|
<string id="FldTempLow">最低气温</string>
|
||||||
|
<string id="FldHumidity">湿度</string>
|
||||||
|
<string id="FldPrecip">降水概率</string>
|
||||||
|
<string id="FldWindSpeed">风速</string>
|
||||||
|
<string id="FldWindBearing">风向角</string>
|
||||||
|
<string id="FldWindDir">风向</string>
|
||||||
|
<string id="FldWeatherTime">天气更新时间</string>
|
||||||
|
<string id="FldPressureSea">海平面气压</string>
|
||||||
|
<string id="FldUvi">紫外线指数</string>
|
||||||
|
<string id="FldVisibility">能见度</string>
|
||||||
|
<string id="FldDewPoint">露点</string>
|
||||||
|
<string id="FldOwmTemp">OWM 气温</string>
|
||||||
|
<string id="FldOwmMinMax">OWM 最高/最低</string>
|
||||||
|
<string id="FldOwmFeels">OWM 体感温度</string>
|
||||||
|
<string id="FldOwmMax">OWM 最高温</string>
|
||||||
|
<string id="FldOwmMin">OWM 最低温</string>
|
||||||
|
<string id="FldOwmDescShort">OWM 天气</string>
|
||||||
|
<string id="FldOwmDescLong">OWM 天气详述</string>
|
||||||
|
<string id="FldOwmHumidity">OWM 湿度</string>
|
||||||
|
<string id="FldOwmPressure">OWM 气压</string>
|
||||||
|
<string id="FldOwmVisibility">OWM 能见度</string>
|
||||||
|
<string id="FldOwmWind">OWM 风速</string>
|
||||||
|
<string id="FldOwmWindDeg">OWM 风向角</string>
|
||||||
|
<string id="FldOwmWindDir">OWM 风向</string>
|
||||||
|
<string id="FldOwmGust">OWM 阵风</string>
|
||||||
|
<string id="FldOwmRain">OWM 1h 降雨</string>
|
||||||
|
<string id="FldOwmSnow">OWM 1h 降雪</string>
|
||||||
|
<string id="FldOwmClouds">OWM 云量</string>
|
||||||
|
<string id="FldOwmPrecip">OWM 降水概率</string>
|
||||||
|
<string id="FldOwmDew">OWM 露点</string>
|
||||||
|
<string id="FldOwmTomorrow">OWM 明日气温</string>
|
||||||
|
<string id="FldOwmMinMaxTmrw">OWM 明日高低温</string>
|
||||||
|
<string id="FldOwmDayAfter">OWM 后日气温</string>
|
||||||
|
<string id="FldOwmMinMaxD2">OWM 后日高低温</string>
|
||||||
|
<string id="FldOwmCity">OWM 城市</string>
|
||||||
|
<string id="FldOwmUpdated">OWM 更新时间</string>
|
||||||
|
<string id="FldCustom1">自定义文字 1</string>
|
||||||
|
<string id="FldCustom2">自定义文字 2</string>
|
||||||
|
<string id="FldCustom3">自定义文字 3</string>
|
||||||
|
|
||||||
|
<!-- On-face captions, in FieldTable.IDS order. -->
|
||||||
|
<string id="FieldLabels">,时间,UTC,时区1,时区2,日期,日期,日期,日,星期,星期,月,月,,周,秒,秒,周,步数,步数,还差,千卡,活动,分钟,中强度,高强度,周分钟,距离,楼层,下楼,爬升,推行,推行,周步数,7天,周千卡,7天,周距离,7天,周楼层,7天,7天,心率,静息,压力,血氧,呼吸,体能,活动,活动,体重,BMI,恢复,摄氧,摄氧,电量,天数,电量,闹钟,通知,勿扰,蓝牙,太阳能,海拔,气压,气压,日出,日落,太阳,太阳,月龄,月相,民用晨,航海晨,天文晨,蓝调晨,黄金晨,黄金昏,蓝调昏,民用昏,航海昏,天文昏,气温,高低温,体感,最高,最低,湿度,降水,风速,风向,风向,更新,气压,紫外线,能见度,露点,气温,高低温,体感,最高,最低,天气,天气,湿度,气压,能见度,风速,风向,风向,阵风,降雨,降雪,云量,降水,露点,明日,明日,后日,后日,城市,更新,,,</string>
|
||||||
|
|
||||||
|
<string id="MetricSteps">步数</string>
|
||||||
|
<string id="MetricCalories">卡路里</string>
|
||||||
|
<string id="MetricDistance">距离</string>
|
||||||
|
<string id="MetricFloors">楼层</string>
|
||||||
|
<string id="MetricMoveBar">动动条</string>
|
||||||
|
<string id="MetricActiveMin">活动分钟</string>
|
||||||
|
<string id="MetricBodyBattery">身体电量</string>
|
||||||
|
</strings>
|
||||||
41
resources-chn/strings/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<strings>
|
||||||
|
<string id="AppName">Fenix 8 V3</string>
|
||||||
|
|
||||||
|
<string id="SettingTheme">主题</string>
|
||||||
|
<string id="SettingRingTR">进度环 · 右上</string>
|
||||||
|
<string id="SettingRingTL">进度环 · 左上</string>
|
||||||
|
<string id="SettingRingBR">进度环 · 右下</string>
|
||||||
|
<string id="SettingRingBL">进度环 · 左下</string>
|
||||||
|
<string id="SettingLeftTop">左上数据</string>
|
||||||
|
<string id="SettingRightTop">右上数据</string>
|
||||||
|
<string id="SettingBottomLeft">左下数据</string>
|
||||||
|
<string id="SettingBottomRight">右下数据</string>
|
||||||
|
<string id="SettingTempUnit">温度单位</string>
|
||||||
|
<string id="SettingAltOffset1">第二时区 1(相对本地分钟数)</string>
|
||||||
|
<string id="SettingAltOffset2">第二时区 2(相对本地分钟数)</string>
|
||||||
|
<string id="SettingOwmKey">OpenWeatherMap API 密钥</string>
|
||||||
|
<string id="SettingCustom1">自定义文字 1</string>
|
||||||
|
<string id="SettingCustom2">自定义文字 2</string>
|
||||||
|
<string id="SettingCustom3">自定义文字 3</string>
|
||||||
|
<string id="SettingShowTop">显示顶部一行</string>
|
||||||
|
<string id="SettingShowBand">显示日期带</string>
|
||||||
|
<string id="SettingShowBottom">显示底部一行</string>
|
||||||
|
|
||||||
|
<string id="TempUnitAuto">跟随手表</string>
|
||||||
|
<string id="TempUnitC">摄氏度 (°C)</string>
|
||||||
|
<string id="TempUnitF">华氏度 (°F)</string>
|
||||||
|
|
||||||
|
<string id="ThemeEmber">1 · 余烬</string>
|
||||||
|
<string id="ThemeAurora">2 · 极光</string>
|
||||||
|
<string id="ThemeBrass">3 · 黄铜</string>
|
||||||
|
<string id="ThemeVoltage">4 · 电压</string>
|
||||||
|
<string id="ThemeReef">5 · 珊瑚礁</string>
|
||||||
|
<string id="ThemeAcid">6 · 酸绿</string>
|
||||||
|
<string id="ThemeKelp">7 · 海藻</string>
|
||||||
|
|
||||||
|
<!-- 表盘上的文案。逗号分隔的列表在运行时拆分。 -->
|
||||||
|
<string id="DowNames">周日,周一,周二,周三,周四,周五,周六</string>
|
||||||
|
<string id="MonNames">1月,2月,3月,4月,5月,6月,7月,8月,9月,10月,11月,12月</string>
|
||||||
|
<string id="DateFormat">$1$$2$日</string>
|
||||||
|
<string id="Meridiem">上午,下午</string>
|
||||||
|
</strings>
|
||||||
5
resources-icons-240/drawables/drawables.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<drawables>
|
||||||
|
<bitmap id="IconSunrise" filename="sunrise.png" packingFormat="png"/>
|
||||||
|
<bitmap id="IconSunset" filename="sunset.png" packingFormat="png"/>
|
||||||
|
<bitmap id="IconWeather" filename="weather.png" packingFormat="png"/>
|
||||||
|
</drawables>
|
||||||
BIN
resources-icons-240/drawables/sunrise.png
Normal file
|
After Width: | Height: | Size: 155 B |
BIN
resources-icons-240/drawables/sunset.png
Normal file
|
After Width: | Height: | Size: 148 B |
BIN
resources-icons-240/drawables/weather.png
Normal file
|
After Width: | Height: | Size: 157 B |
5
resources-icons-260/drawables/drawables.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<drawables>
|
||||||
|
<bitmap id="IconSunrise" filename="sunrise.png" packingFormat="png"/>
|
||||||
|
<bitmap id="IconSunset" filename="sunset.png" packingFormat="png"/>
|
||||||
|
<bitmap id="IconWeather" filename="weather.png" packingFormat="png"/>
|
||||||
|
</drawables>
|
||||||
BIN
resources-icons-260/drawables/sunrise.png
Normal file
|
After Width: | Height: | Size: 172 B |
BIN
resources-icons-260/drawables/sunset.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
resources-icons-260/drawables/weather.png
Normal file
|
After Width: | Height: | Size: 163 B |
5
resources-icons-280/drawables/drawables.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<drawables>
|
||||||
|
<bitmap id="IconSunrise" filename="sunrise.png" packingFormat="png"/>
|
||||||
|
<bitmap id="IconSunset" filename="sunset.png" packingFormat="png"/>
|
||||||
|
<bitmap id="IconWeather" filename="weather.png" packingFormat="png"/>
|
||||||
|
</drawables>
|
||||||
BIN
resources-icons-280/drawables/sunrise.png
Normal file
|
After Width: | Height: | Size: 172 B |
BIN
resources-icons-280/drawables/sunset.png
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
resources-icons-280/drawables/weather.png
Normal file
|
After Width: | Height: | Size: 173 B |
5
resources-icons-390/drawables/drawables.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<drawables>
|
||||||
|
<bitmap id="IconSunrise" filename="sunrise.png" packingFormat="png"/>
|
||||||
|
<bitmap id="IconSunset" filename="sunset.png" packingFormat="png"/>
|
||||||
|
<bitmap id="IconWeather" filename="weather.png" packingFormat="png"/>
|
||||||
|
</drawables>
|
||||||
BIN
resources-icons-390/drawables/sunrise.png
Normal file
|
After Width: | Height: | Size: 636 B |
BIN
resources-icons-390/drawables/sunset.png
Normal file
|
After Width: | Height: | Size: 662 B |
BIN
resources-icons-390/drawables/weather.png
Normal file
|
After Width: | Height: | Size: 677 B |
5
resources-icons-416/drawables/drawables.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<drawables>
|
||||||
|
<bitmap id="IconSunrise" filename="sunrise.png" packingFormat="png"/>
|
||||||
|
<bitmap id="IconSunset" filename="sunset.png" packingFormat="png"/>
|
||||||
|
<bitmap id="IconWeather" filename="weather.png" packingFormat="png"/>
|
||||||
|
</drawables>
|
||||||
BIN
resources-icons-416/drawables/sunrise.png
Normal file
|
After Width: | Height: | Size: 701 B |
BIN
resources-icons-416/drawables/sunset.png
Normal file
|
After Width: | Height: | Size: 733 B |
BIN
resources-icons-416/drawables/weather.png
Normal file
|
After Width: | Height: | Size: 746 B |
5
resources-icons-454/drawables/drawables.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<drawables>
|
||||||
|
<bitmap id="IconSunrise" filename="sunrise.png" packingFormat="png"/>
|
||||||
|
<bitmap id="IconSunset" filename="sunset.png" packingFormat="png"/>
|
||||||
|
<bitmap id="IconWeather" filename="weather.png" packingFormat="png"/>
|
||||||
|
</drawables>
|
||||||
BIN
resources-icons-454/drawables/sunrise.png
Normal file
|
After Width: | Height: | Size: 767 B |
BIN
resources-icons-454/drawables/sunset.png
Normal file
|
After Width: | Height: | Size: 783 B |
BIN
resources-icons-454/drawables/weather.png
Normal file
|
After Width: | Height: | Size: 829 B |
BIN
resources-launcher-40/drawables/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
3
resources-launcher-56/drawables/drawables.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<drawables>
|
||||||
|
<bitmap id="LauncherIcon" filename="launcher_icon.png"/>
|
||||||
|
</drawables>
|
||||||
BIN
resources-launcher-56/drawables/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
3
resources-launcher-60/drawables/drawables.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<drawables>
|
||||||
|
<bitmap id="LauncherIcon" filename="launcher_icon.png"/>
|
||||||
|
</drawables>
|
||||||
BIN
resources-launcher-60/drawables/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
3
resources-launcher-65/drawables/drawables.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<drawables>
|
||||||
|
<bitmap id="LauncherIcon" filename="launcher_icon.png"/>
|
||||||
|
</drawables>
|
||||||
BIN
resources-launcher-65/drawables/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
3
resources-launcher-70/drawables/drawables.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<drawables>
|
||||||
|
<bitmap id="LauncherIcon" filename="launcher_icon.png"/>
|
||||||
|
</drawables>
|
||||||
BIN
resources-launcher-70/drawables/launcher_icon.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 498 B |
@@ -4,8 +4,19 @@
|
|||||||
<property id="RingTL" type="number">1</property>
|
<property id="RingTL" type="number">1</property>
|
||||||
<property id="RingBR" type="number">4</property>
|
<property id="RingBR" type="number">4</property>
|
||||||
<property id="RingBL" type="number">3</property>
|
<property id="RingBL" type="number">3</property>
|
||||||
<property id="LeftTop" type="number">1</property>
|
<!-- Field ids follow https://watchface.io/docs/datafields; the defaults are
|
||||||
<property id="RightTop" type="number">0</property>
|
the comps: sunrise, forecast high/low, distance, steps. -->
|
||||||
|
<property id="LeftTop" type="number">712</property>
|
||||||
|
<property id="RightTop" type="number">639</property>
|
||||||
|
<property id="BottomLeft" type="number">3</property>
|
||||||
|
<property id="BottomRight" type="number">1</property>
|
||||||
|
<property id="TempUnit" type="number">0</property>
|
||||||
|
<property id="AltOffset1" type="number">0</property>
|
||||||
|
<property id="AltOffset2" type="number">0</property>
|
||||||
|
<property id="OwmKey" type="string"></property>
|
||||||
|
<property id="Custom1" type="string"></property>
|
||||||
|
<property id="Custom2" type="string"></property>
|
||||||
|
<property id="Custom3" type="string"></property>
|
||||||
<property id="ShowTop" type="boolean">true</property>
|
<property id="ShowTop" type="boolean">true</property>
|
||||||
<property id="ShowBand" type="boolean">true</property>
|
<property id="ShowBand" type="boolean">true</property>
|
||||||
<property id="ShowBottom" type="boolean">true</property>
|
<property id="ShowBottom" type="boolean">true</property>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<settings>
|
<settings>
|
||||||
|
|
||||||
<setting propertyKey="@Properties.Theme" title="@Strings.SettingTheme">
|
<setting propertyKey="@Properties.Theme" title="@Strings.SettingTheme">
|
||||||
<settingConfig type="list">
|
<settingConfig type="list">
|
||||||
<listEntry value="1">@Strings.ThemeEmber</listEntry>
|
<listEntry value="1">@Strings.ThemeEmber</listEntry>
|
||||||
@@ -11,6 +12,526 @@
|
|||||||
</settingConfig>
|
</settingConfig>
|
||||||
</setting>
|
</setting>
|
||||||
|
|
||||||
|
<setting propertyKey="@Properties.LeftTop" title="@Strings.SettingLeftTop">
|
||||||
|
<settingConfig type="list">
|
||||||
|
<listEntry value="0">@Strings.FldOff</listEntry>
|
||||||
|
<listEntry value="860">@Strings.FldTime</listEntry>
|
||||||
|
<listEntry value="880">@Strings.FldUtcTime</listEntry>
|
||||||
|
<listEntry value="864">@Strings.FldAltTime1</listEntry>
|
||||||
|
<listEntry value="865">@Strings.FldAltTime2</listEntry>
|
||||||
|
<listEntry value="851">@Strings.FldDayMonthDay</listEntry>
|
||||||
|
<listEntry value="872">@Strings.FldDayDate</listEntry>
|
||||||
|
<listEntry value="852">@Strings.FldMonthDay</listEntry>
|
||||||
|
<listEntry value="855">@Strings.FldDayOfMonth</listEntry>
|
||||||
|
<listEntry value="870">@Strings.FldDayShort</listEntry>
|
||||||
|
<listEntry value="856">@Strings.FldDayFull</listEntry>
|
||||||
|
<listEntry value="853">@Strings.FldMonthName</listEntry>
|
||||||
|
<listEntry value="868">@Strings.FldMonthNum</listEntry>
|
||||||
|
<listEntry value="858">@Strings.FldMeridiem</listEntry>
|
||||||
|
<listEntry value="859">@Strings.FldWeekIso</listEntry>
|
||||||
|
<listEntry value="850">@Strings.FldSeconds</listEntry>
|
||||||
|
<listEntry value="869">@Strings.FldSeconds0</listEntry>
|
||||||
|
<listEntry value="873">@Strings.FldWeekCal</listEntry>
|
||||||
|
<listEntry value="1">@Strings.FldSteps</listEntry>
|
||||||
|
<listEntry value="14">@Strings.FldStepsRound</listEntry>
|
||||||
|
<listEntry value="707">@Strings.FldStepsToGo</listEntry>
|
||||||
|
<listEntry value="2">@Strings.FldCalories</listEntry>
|
||||||
|
<listEntry value="603">@Strings.FldActiveCal</listEntry>
|
||||||
|
<listEntry value="150">@Strings.FldActiveMin</listEntry>
|
||||||
|
<listEntry value="152">@Strings.FldActiveMod</listEntry>
|
||||||
|
<listEntry value="154">@Strings.FldActiveVig</listEntry>
|
||||||
|
<listEntry value="151">@Strings.FldActiveWeek</listEntry>
|
||||||
|
<listEntry value="3">@Strings.FldDistance</listEntry>
|
||||||
|
<listEntry value="4">@Strings.FldFloorsUp</listEntry>
|
||||||
|
<listEntry value="5">@Strings.FldFloorsDown</listEntry>
|
||||||
|
<listEntry value="6">@Strings.FldMetersUp</listEntry>
|
||||||
|
<listEntry value="9">@Strings.FldPushDist</listEntry>
|
||||||
|
<listEntry value="10">@Strings.FldPushes</listEntry>
|
||||||
|
<listEntry value="219">@Strings.FldStepsWeek</listEntry>
|
||||||
|
<listEntry value="220">@Strings.FldSteps7d</listEntry>
|
||||||
|
<listEntry value="211">@Strings.FldCalWeek</listEntry>
|
||||||
|
<listEntry value="212">@Strings.FldCal7d</listEntry>
|
||||||
|
<listEntry value="213">@Strings.FldDistWeek</listEntry>
|
||||||
|
<listEntry value="214">@Strings.FldDist7d</listEntry>
|
||||||
|
<listEntry value="203">@Strings.FldFloorsWeek</listEntry>
|
||||||
|
<listEntry value="204">@Strings.FldFloors7d</listEntry>
|
||||||
|
<listEntry value="200">@Strings.FldActiveMin7d</listEntry>
|
||||||
|
<listEntry value="618">@Strings.FldHeartRate</listEntry>
|
||||||
|
<listEntry value="276">@Strings.FldRestingHr</listEntry>
|
||||||
|
<listEntry value="12">@Strings.FldStress</listEntry>
|
||||||
|
<listEntry value="635">@Strings.FldPulseOx</listEntry>
|
||||||
|
<listEntry value="11">@Strings.FldRespiration</listEntry>
|
||||||
|
<listEntry value="623">@Strings.FldBodyBattery</listEntry>
|
||||||
|
<listEntry value="8">@Strings.FldMoveBar</listEntry>
|
||||||
|
<listEntry value="15">@Strings.FldMoveBarRev</listEntry>
|
||||||
|
<listEntry value="281">@Strings.FldWeight</listEntry>
|
||||||
|
<listEntry value="742">@Strings.FldBmi</listEntry>
|
||||||
|
<listEntry value="13">@Strings.FldRecovery</listEntry>
|
||||||
|
<listEntry value="278">@Strings.FldVo2Run</listEntry>
|
||||||
|
<listEntry value="277">@Strings.FldVo2Bike</listEntry>
|
||||||
|
<listEntry value="250">@Strings.FldBattery</listEntry>
|
||||||
|
<listEntry value="251">@Strings.FldBatteryDays</listEntry>
|
||||||
|
<listEntry value="257">@Strings.FldBatteryBoth</listEntry>
|
||||||
|
<listEntry value="253">@Strings.FldAlarms</listEntry>
|
||||||
|
<listEntry value="255">@Strings.FldNotifications</listEntry>
|
||||||
|
<listEntry value="254">@Strings.FldDoNotDisturb</listEntry>
|
||||||
|
<listEntry value="256">@Strings.FldBluetooth</listEntry>
|
||||||
|
<listEntry value="252">@Strings.FldSolar</listEntry>
|
||||||
|
<listEntry value="615">@Strings.FldAltitude</listEntry>
|
||||||
|
<listEntry value="616">@Strings.FldSeaPressure</listEntry>
|
||||||
|
<listEntry value="733">@Strings.FldAmbPressure</listEntry>
|
||||||
|
<listEntry value="712">@Strings.FldSunrise</listEntry>
|
||||||
|
<listEntry value="717">@Strings.FldSunset</listEntry>
|
||||||
|
<listEntry value="700">@Strings.FldNextSun</listEntry>
|
||||||
|
<listEntry value="731">@Strings.FldUntilSun</listEntry>
|
||||||
|
<listEntry value="706">@Strings.FldMoonAge</listEntry>
|
||||||
|
<listEntry value="732">@Strings.FldMoonLit</listEntry>
|
||||||
|
<listEntry value="710">@Strings.FldCivilAm</listEntry>
|
||||||
|
<listEntry value="709">@Strings.FldNauticalAm</listEntry>
|
||||||
|
<listEntry value="708">@Strings.FldAstroAm</listEntry>
|
||||||
|
<listEntry value="711">@Strings.FldBlueAm</listEntry>
|
||||||
|
<listEntry value="714">@Strings.FldGoldenAm</listEntry>
|
||||||
|
<listEntry value="715">@Strings.FldGoldenPm</listEntry>
|
||||||
|
<listEntry value="718">@Strings.FldBluePm</listEntry>
|
||||||
|
<listEntry value="719">@Strings.FldCivilPm</listEntry>
|
||||||
|
<listEntry value="720">@Strings.FldNauticalPm</listEntry>
|
||||||
|
<listEntry value="721">@Strings.FldAstroPm</listEntry>
|
||||||
|
<listEntry value="306">@Strings.FldTemp</listEntry>
|
||||||
|
<listEntry value="639">@Strings.FldTempHiLo</listEntry>
|
||||||
|
<listEntry value="300">@Strings.FldFeelsLike</listEntry>
|
||||||
|
<listEntry value="301">@Strings.FldTempHigh</listEntry>
|
||||||
|
<listEntry value="302">@Strings.FldTempLow</listEntry>
|
||||||
|
<listEntry value="305">@Strings.FldHumidity</listEntry>
|
||||||
|
<listEntry value="304">@Strings.FldPrecip</listEntry>
|
||||||
|
<listEntry value="308">@Strings.FldWindSpeed</listEntry>
|
||||||
|
<listEntry value="307">@Strings.FldWindBearing</listEntry>
|
||||||
|
<listEntry value="310">@Strings.FldWindDir</listEntry>
|
||||||
|
<listEntry value="309">@Strings.FldWeatherTime</listEntry>
|
||||||
|
<listEntry value="312">@Strings.FldPressureSea</listEntry>
|
||||||
|
<listEntry value="313">@Strings.FldUvi</listEntry>
|
||||||
|
<listEntry value="314">@Strings.FldVisibility</listEntry>
|
||||||
|
<listEntry value="315">@Strings.FldDewPoint</listEntry>
|
||||||
|
<listEntry value="500">@Strings.FldOwmTemp</listEntry>
|
||||||
|
<listEntry value="515">@Strings.FldOwmMinMax</listEntry>
|
||||||
|
<listEntry value="503">@Strings.FldOwmFeels</listEntry>
|
||||||
|
<listEntry value="504">@Strings.FldOwmMax</listEntry>
|
||||||
|
<listEntry value="505">@Strings.FldOwmMin</listEntry>
|
||||||
|
<listEntry value="507">@Strings.FldOwmDescShort</listEntry>
|
||||||
|
<listEntry value="502">@Strings.FldOwmDescLong</listEntry>
|
||||||
|
<listEntry value="501">@Strings.FldOwmHumidity</listEntry>
|
||||||
|
<listEntry value="506">@Strings.FldOwmPressure</listEntry>
|
||||||
|
<listEntry value="509">@Strings.FldOwmVisibility</listEntry>
|
||||||
|
<listEntry value="510">@Strings.FldOwmWind</listEntry>
|
||||||
|
<listEntry value="511">@Strings.FldOwmWindDeg</listEntry>
|
||||||
|
<listEntry value="518">@Strings.FldOwmWindDir</listEntry>
|
||||||
|
<listEntry value="512">@Strings.FldOwmGust</listEntry>
|
||||||
|
<listEntry value="513">@Strings.FldOwmRain</listEntry>
|
||||||
|
<listEntry value="532">@Strings.FldOwmSnow</listEntry>
|
||||||
|
<listEntry value="536">@Strings.FldOwmClouds</listEntry>
|
||||||
|
<listEntry value="537">@Strings.FldOwmPrecip</listEntry>
|
||||||
|
<listEntry value="530">@Strings.FldOwmDew</listEntry>
|
||||||
|
<listEntry value="534">@Strings.FldOwmTomorrow</listEntry>
|
||||||
|
<listEntry value="522">@Strings.FldOwmMinMaxTmrw</listEntry>
|
||||||
|
<listEntry value="535">@Strings.FldOwmDayAfter</listEntry>
|
||||||
|
<listEntry value="523">@Strings.FldOwmMinMaxD2</listEntry>
|
||||||
|
<listEntry value="516">@Strings.FldOwmCity</listEntry>
|
||||||
|
<listEntry value="517">@Strings.FldOwmUpdated</listEntry>
|
||||||
|
<listEntry value="703">@Strings.FldCustom1</listEntry>
|
||||||
|
<listEntry value="704">@Strings.FldCustom2</listEntry>
|
||||||
|
<listEntry value="705">@Strings.FldCustom3</listEntry>
|
||||||
|
</settingConfig>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting propertyKey="@Properties.RightTop" title="@Strings.SettingRightTop">
|
||||||
|
<settingConfig type="list">
|
||||||
|
<listEntry value="0">@Strings.FldOff</listEntry>
|
||||||
|
<listEntry value="860">@Strings.FldTime</listEntry>
|
||||||
|
<listEntry value="880">@Strings.FldUtcTime</listEntry>
|
||||||
|
<listEntry value="864">@Strings.FldAltTime1</listEntry>
|
||||||
|
<listEntry value="865">@Strings.FldAltTime2</listEntry>
|
||||||
|
<listEntry value="851">@Strings.FldDayMonthDay</listEntry>
|
||||||
|
<listEntry value="872">@Strings.FldDayDate</listEntry>
|
||||||
|
<listEntry value="852">@Strings.FldMonthDay</listEntry>
|
||||||
|
<listEntry value="855">@Strings.FldDayOfMonth</listEntry>
|
||||||
|
<listEntry value="870">@Strings.FldDayShort</listEntry>
|
||||||
|
<listEntry value="856">@Strings.FldDayFull</listEntry>
|
||||||
|
<listEntry value="853">@Strings.FldMonthName</listEntry>
|
||||||
|
<listEntry value="868">@Strings.FldMonthNum</listEntry>
|
||||||
|
<listEntry value="858">@Strings.FldMeridiem</listEntry>
|
||||||
|
<listEntry value="859">@Strings.FldWeekIso</listEntry>
|
||||||
|
<listEntry value="850">@Strings.FldSeconds</listEntry>
|
||||||
|
<listEntry value="869">@Strings.FldSeconds0</listEntry>
|
||||||
|
<listEntry value="873">@Strings.FldWeekCal</listEntry>
|
||||||
|
<listEntry value="1">@Strings.FldSteps</listEntry>
|
||||||
|
<listEntry value="14">@Strings.FldStepsRound</listEntry>
|
||||||
|
<listEntry value="707">@Strings.FldStepsToGo</listEntry>
|
||||||
|
<listEntry value="2">@Strings.FldCalories</listEntry>
|
||||||
|
<listEntry value="603">@Strings.FldActiveCal</listEntry>
|
||||||
|
<listEntry value="150">@Strings.FldActiveMin</listEntry>
|
||||||
|
<listEntry value="152">@Strings.FldActiveMod</listEntry>
|
||||||
|
<listEntry value="154">@Strings.FldActiveVig</listEntry>
|
||||||
|
<listEntry value="151">@Strings.FldActiveWeek</listEntry>
|
||||||
|
<listEntry value="3">@Strings.FldDistance</listEntry>
|
||||||
|
<listEntry value="4">@Strings.FldFloorsUp</listEntry>
|
||||||
|
<listEntry value="5">@Strings.FldFloorsDown</listEntry>
|
||||||
|
<listEntry value="6">@Strings.FldMetersUp</listEntry>
|
||||||
|
<listEntry value="9">@Strings.FldPushDist</listEntry>
|
||||||
|
<listEntry value="10">@Strings.FldPushes</listEntry>
|
||||||
|
<listEntry value="219">@Strings.FldStepsWeek</listEntry>
|
||||||
|
<listEntry value="220">@Strings.FldSteps7d</listEntry>
|
||||||
|
<listEntry value="211">@Strings.FldCalWeek</listEntry>
|
||||||
|
<listEntry value="212">@Strings.FldCal7d</listEntry>
|
||||||
|
<listEntry value="213">@Strings.FldDistWeek</listEntry>
|
||||||
|
<listEntry value="214">@Strings.FldDist7d</listEntry>
|
||||||
|
<listEntry value="203">@Strings.FldFloorsWeek</listEntry>
|
||||||
|
<listEntry value="204">@Strings.FldFloors7d</listEntry>
|
||||||
|
<listEntry value="200">@Strings.FldActiveMin7d</listEntry>
|
||||||
|
<listEntry value="618">@Strings.FldHeartRate</listEntry>
|
||||||
|
<listEntry value="276">@Strings.FldRestingHr</listEntry>
|
||||||
|
<listEntry value="12">@Strings.FldStress</listEntry>
|
||||||
|
<listEntry value="635">@Strings.FldPulseOx</listEntry>
|
||||||
|
<listEntry value="11">@Strings.FldRespiration</listEntry>
|
||||||
|
<listEntry value="623">@Strings.FldBodyBattery</listEntry>
|
||||||
|
<listEntry value="8">@Strings.FldMoveBar</listEntry>
|
||||||
|
<listEntry value="15">@Strings.FldMoveBarRev</listEntry>
|
||||||
|
<listEntry value="281">@Strings.FldWeight</listEntry>
|
||||||
|
<listEntry value="742">@Strings.FldBmi</listEntry>
|
||||||
|
<listEntry value="13">@Strings.FldRecovery</listEntry>
|
||||||
|
<listEntry value="278">@Strings.FldVo2Run</listEntry>
|
||||||
|
<listEntry value="277">@Strings.FldVo2Bike</listEntry>
|
||||||
|
<listEntry value="250">@Strings.FldBattery</listEntry>
|
||||||
|
<listEntry value="251">@Strings.FldBatteryDays</listEntry>
|
||||||
|
<listEntry value="257">@Strings.FldBatteryBoth</listEntry>
|
||||||
|
<listEntry value="253">@Strings.FldAlarms</listEntry>
|
||||||
|
<listEntry value="255">@Strings.FldNotifications</listEntry>
|
||||||
|
<listEntry value="254">@Strings.FldDoNotDisturb</listEntry>
|
||||||
|
<listEntry value="256">@Strings.FldBluetooth</listEntry>
|
||||||
|
<listEntry value="252">@Strings.FldSolar</listEntry>
|
||||||
|
<listEntry value="615">@Strings.FldAltitude</listEntry>
|
||||||
|
<listEntry value="616">@Strings.FldSeaPressure</listEntry>
|
||||||
|
<listEntry value="733">@Strings.FldAmbPressure</listEntry>
|
||||||
|
<listEntry value="712">@Strings.FldSunrise</listEntry>
|
||||||
|
<listEntry value="717">@Strings.FldSunset</listEntry>
|
||||||
|
<listEntry value="700">@Strings.FldNextSun</listEntry>
|
||||||
|
<listEntry value="731">@Strings.FldUntilSun</listEntry>
|
||||||
|
<listEntry value="706">@Strings.FldMoonAge</listEntry>
|
||||||
|
<listEntry value="732">@Strings.FldMoonLit</listEntry>
|
||||||
|
<listEntry value="710">@Strings.FldCivilAm</listEntry>
|
||||||
|
<listEntry value="709">@Strings.FldNauticalAm</listEntry>
|
||||||
|
<listEntry value="708">@Strings.FldAstroAm</listEntry>
|
||||||
|
<listEntry value="711">@Strings.FldBlueAm</listEntry>
|
||||||
|
<listEntry value="714">@Strings.FldGoldenAm</listEntry>
|
||||||
|
<listEntry value="715">@Strings.FldGoldenPm</listEntry>
|
||||||
|
<listEntry value="718">@Strings.FldBluePm</listEntry>
|
||||||
|
<listEntry value="719">@Strings.FldCivilPm</listEntry>
|
||||||
|
<listEntry value="720">@Strings.FldNauticalPm</listEntry>
|
||||||
|
<listEntry value="721">@Strings.FldAstroPm</listEntry>
|
||||||
|
<listEntry value="306">@Strings.FldTemp</listEntry>
|
||||||
|
<listEntry value="639">@Strings.FldTempHiLo</listEntry>
|
||||||
|
<listEntry value="300">@Strings.FldFeelsLike</listEntry>
|
||||||
|
<listEntry value="301">@Strings.FldTempHigh</listEntry>
|
||||||
|
<listEntry value="302">@Strings.FldTempLow</listEntry>
|
||||||
|
<listEntry value="305">@Strings.FldHumidity</listEntry>
|
||||||
|
<listEntry value="304">@Strings.FldPrecip</listEntry>
|
||||||
|
<listEntry value="308">@Strings.FldWindSpeed</listEntry>
|
||||||
|
<listEntry value="307">@Strings.FldWindBearing</listEntry>
|
||||||
|
<listEntry value="310">@Strings.FldWindDir</listEntry>
|
||||||
|
<listEntry value="309">@Strings.FldWeatherTime</listEntry>
|
||||||
|
<listEntry value="312">@Strings.FldPressureSea</listEntry>
|
||||||
|
<listEntry value="313">@Strings.FldUvi</listEntry>
|
||||||
|
<listEntry value="314">@Strings.FldVisibility</listEntry>
|
||||||
|
<listEntry value="315">@Strings.FldDewPoint</listEntry>
|
||||||
|
<listEntry value="500">@Strings.FldOwmTemp</listEntry>
|
||||||
|
<listEntry value="515">@Strings.FldOwmMinMax</listEntry>
|
||||||
|
<listEntry value="503">@Strings.FldOwmFeels</listEntry>
|
||||||
|
<listEntry value="504">@Strings.FldOwmMax</listEntry>
|
||||||
|
<listEntry value="505">@Strings.FldOwmMin</listEntry>
|
||||||
|
<listEntry value="507">@Strings.FldOwmDescShort</listEntry>
|
||||||
|
<listEntry value="502">@Strings.FldOwmDescLong</listEntry>
|
||||||
|
<listEntry value="501">@Strings.FldOwmHumidity</listEntry>
|
||||||
|
<listEntry value="506">@Strings.FldOwmPressure</listEntry>
|
||||||
|
<listEntry value="509">@Strings.FldOwmVisibility</listEntry>
|
||||||
|
<listEntry value="510">@Strings.FldOwmWind</listEntry>
|
||||||
|
<listEntry value="511">@Strings.FldOwmWindDeg</listEntry>
|
||||||
|
<listEntry value="518">@Strings.FldOwmWindDir</listEntry>
|
||||||
|
<listEntry value="512">@Strings.FldOwmGust</listEntry>
|
||||||
|
<listEntry value="513">@Strings.FldOwmRain</listEntry>
|
||||||
|
<listEntry value="532">@Strings.FldOwmSnow</listEntry>
|
||||||
|
<listEntry value="536">@Strings.FldOwmClouds</listEntry>
|
||||||
|
<listEntry value="537">@Strings.FldOwmPrecip</listEntry>
|
||||||
|
<listEntry value="530">@Strings.FldOwmDew</listEntry>
|
||||||
|
<listEntry value="534">@Strings.FldOwmTomorrow</listEntry>
|
||||||
|
<listEntry value="522">@Strings.FldOwmMinMaxTmrw</listEntry>
|
||||||
|
<listEntry value="535">@Strings.FldOwmDayAfter</listEntry>
|
||||||
|
<listEntry value="523">@Strings.FldOwmMinMaxD2</listEntry>
|
||||||
|
<listEntry value="516">@Strings.FldOwmCity</listEntry>
|
||||||
|
<listEntry value="517">@Strings.FldOwmUpdated</listEntry>
|
||||||
|
<listEntry value="703">@Strings.FldCustom1</listEntry>
|
||||||
|
<listEntry value="704">@Strings.FldCustom2</listEntry>
|
||||||
|
<listEntry value="705">@Strings.FldCustom3</listEntry>
|
||||||
|
</settingConfig>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting propertyKey="@Properties.BottomLeft" title="@Strings.SettingBottomLeft">
|
||||||
|
<settingConfig type="list">
|
||||||
|
<listEntry value="0">@Strings.FldOff</listEntry>
|
||||||
|
<listEntry value="860">@Strings.FldTime</listEntry>
|
||||||
|
<listEntry value="880">@Strings.FldUtcTime</listEntry>
|
||||||
|
<listEntry value="864">@Strings.FldAltTime1</listEntry>
|
||||||
|
<listEntry value="865">@Strings.FldAltTime2</listEntry>
|
||||||
|
<listEntry value="851">@Strings.FldDayMonthDay</listEntry>
|
||||||
|
<listEntry value="872">@Strings.FldDayDate</listEntry>
|
||||||
|
<listEntry value="852">@Strings.FldMonthDay</listEntry>
|
||||||
|
<listEntry value="855">@Strings.FldDayOfMonth</listEntry>
|
||||||
|
<listEntry value="870">@Strings.FldDayShort</listEntry>
|
||||||
|
<listEntry value="856">@Strings.FldDayFull</listEntry>
|
||||||
|
<listEntry value="853">@Strings.FldMonthName</listEntry>
|
||||||
|
<listEntry value="868">@Strings.FldMonthNum</listEntry>
|
||||||
|
<listEntry value="858">@Strings.FldMeridiem</listEntry>
|
||||||
|
<listEntry value="859">@Strings.FldWeekIso</listEntry>
|
||||||
|
<listEntry value="850">@Strings.FldSeconds</listEntry>
|
||||||
|
<listEntry value="869">@Strings.FldSeconds0</listEntry>
|
||||||
|
<listEntry value="873">@Strings.FldWeekCal</listEntry>
|
||||||
|
<listEntry value="1">@Strings.FldSteps</listEntry>
|
||||||
|
<listEntry value="14">@Strings.FldStepsRound</listEntry>
|
||||||
|
<listEntry value="707">@Strings.FldStepsToGo</listEntry>
|
||||||
|
<listEntry value="2">@Strings.FldCalories</listEntry>
|
||||||
|
<listEntry value="603">@Strings.FldActiveCal</listEntry>
|
||||||
|
<listEntry value="150">@Strings.FldActiveMin</listEntry>
|
||||||
|
<listEntry value="152">@Strings.FldActiveMod</listEntry>
|
||||||
|
<listEntry value="154">@Strings.FldActiveVig</listEntry>
|
||||||
|
<listEntry value="151">@Strings.FldActiveWeek</listEntry>
|
||||||
|
<listEntry value="3">@Strings.FldDistance</listEntry>
|
||||||
|
<listEntry value="4">@Strings.FldFloorsUp</listEntry>
|
||||||
|
<listEntry value="5">@Strings.FldFloorsDown</listEntry>
|
||||||
|
<listEntry value="6">@Strings.FldMetersUp</listEntry>
|
||||||
|
<listEntry value="9">@Strings.FldPushDist</listEntry>
|
||||||
|
<listEntry value="10">@Strings.FldPushes</listEntry>
|
||||||
|
<listEntry value="219">@Strings.FldStepsWeek</listEntry>
|
||||||
|
<listEntry value="220">@Strings.FldSteps7d</listEntry>
|
||||||
|
<listEntry value="211">@Strings.FldCalWeek</listEntry>
|
||||||
|
<listEntry value="212">@Strings.FldCal7d</listEntry>
|
||||||
|
<listEntry value="213">@Strings.FldDistWeek</listEntry>
|
||||||
|
<listEntry value="214">@Strings.FldDist7d</listEntry>
|
||||||
|
<listEntry value="203">@Strings.FldFloorsWeek</listEntry>
|
||||||
|
<listEntry value="204">@Strings.FldFloors7d</listEntry>
|
||||||
|
<listEntry value="200">@Strings.FldActiveMin7d</listEntry>
|
||||||
|
<listEntry value="618">@Strings.FldHeartRate</listEntry>
|
||||||
|
<listEntry value="276">@Strings.FldRestingHr</listEntry>
|
||||||
|
<listEntry value="12">@Strings.FldStress</listEntry>
|
||||||
|
<listEntry value="635">@Strings.FldPulseOx</listEntry>
|
||||||
|
<listEntry value="11">@Strings.FldRespiration</listEntry>
|
||||||
|
<listEntry value="623">@Strings.FldBodyBattery</listEntry>
|
||||||
|
<listEntry value="8">@Strings.FldMoveBar</listEntry>
|
||||||
|
<listEntry value="15">@Strings.FldMoveBarRev</listEntry>
|
||||||
|
<listEntry value="281">@Strings.FldWeight</listEntry>
|
||||||
|
<listEntry value="742">@Strings.FldBmi</listEntry>
|
||||||
|
<listEntry value="13">@Strings.FldRecovery</listEntry>
|
||||||
|
<listEntry value="278">@Strings.FldVo2Run</listEntry>
|
||||||
|
<listEntry value="277">@Strings.FldVo2Bike</listEntry>
|
||||||
|
<listEntry value="250">@Strings.FldBattery</listEntry>
|
||||||
|
<listEntry value="251">@Strings.FldBatteryDays</listEntry>
|
||||||
|
<listEntry value="257">@Strings.FldBatteryBoth</listEntry>
|
||||||
|
<listEntry value="253">@Strings.FldAlarms</listEntry>
|
||||||
|
<listEntry value="255">@Strings.FldNotifications</listEntry>
|
||||||
|
<listEntry value="254">@Strings.FldDoNotDisturb</listEntry>
|
||||||
|
<listEntry value="256">@Strings.FldBluetooth</listEntry>
|
||||||
|
<listEntry value="252">@Strings.FldSolar</listEntry>
|
||||||
|
<listEntry value="615">@Strings.FldAltitude</listEntry>
|
||||||
|
<listEntry value="616">@Strings.FldSeaPressure</listEntry>
|
||||||
|
<listEntry value="733">@Strings.FldAmbPressure</listEntry>
|
||||||
|
<listEntry value="712">@Strings.FldSunrise</listEntry>
|
||||||
|
<listEntry value="717">@Strings.FldSunset</listEntry>
|
||||||
|
<listEntry value="700">@Strings.FldNextSun</listEntry>
|
||||||
|
<listEntry value="731">@Strings.FldUntilSun</listEntry>
|
||||||
|
<listEntry value="706">@Strings.FldMoonAge</listEntry>
|
||||||
|
<listEntry value="732">@Strings.FldMoonLit</listEntry>
|
||||||
|
<listEntry value="710">@Strings.FldCivilAm</listEntry>
|
||||||
|
<listEntry value="709">@Strings.FldNauticalAm</listEntry>
|
||||||
|
<listEntry value="708">@Strings.FldAstroAm</listEntry>
|
||||||
|
<listEntry value="711">@Strings.FldBlueAm</listEntry>
|
||||||
|
<listEntry value="714">@Strings.FldGoldenAm</listEntry>
|
||||||
|
<listEntry value="715">@Strings.FldGoldenPm</listEntry>
|
||||||
|
<listEntry value="718">@Strings.FldBluePm</listEntry>
|
||||||
|
<listEntry value="719">@Strings.FldCivilPm</listEntry>
|
||||||
|
<listEntry value="720">@Strings.FldNauticalPm</listEntry>
|
||||||
|
<listEntry value="721">@Strings.FldAstroPm</listEntry>
|
||||||
|
<listEntry value="306">@Strings.FldTemp</listEntry>
|
||||||
|
<listEntry value="639">@Strings.FldTempHiLo</listEntry>
|
||||||
|
<listEntry value="300">@Strings.FldFeelsLike</listEntry>
|
||||||
|
<listEntry value="301">@Strings.FldTempHigh</listEntry>
|
||||||
|
<listEntry value="302">@Strings.FldTempLow</listEntry>
|
||||||
|
<listEntry value="305">@Strings.FldHumidity</listEntry>
|
||||||
|
<listEntry value="304">@Strings.FldPrecip</listEntry>
|
||||||
|
<listEntry value="308">@Strings.FldWindSpeed</listEntry>
|
||||||
|
<listEntry value="307">@Strings.FldWindBearing</listEntry>
|
||||||
|
<listEntry value="310">@Strings.FldWindDir</listEntry>
|
||||||
|
<listEntry value="309">@Strings.FldWeatherTime</listEntry>
|
||||||
|
<listEntry value="312">@Strings.FldPressureSea</listEntry>
|
||||||
|
<listEntry value="313">@Strings.FldUvi</listEntry>
|
||||||
|
<listEntry value="314">@Strings.FldVisibility</listEntry>
|
||||||
|
<listEntry value="315">@Strings.FldDewPoint</listEntry>
|
||||||
|
<listEntry value="500">@Strings.FldOwmTemp</listEntry>
|
||||||
|
<listEntry value="515">@Strings.FldOwmMinMax</listEntry>
|
||||||
|
<listEntry value="503">@Strings.FldOwmFeels</listEntry>
|
||||||
|
<listEntry value="504">@Strings.FldOwmMax</listEntry>
|
||||||
|
<listEntry value="505">@Strings.FldOwmMin</listEntry>
|
||||||
|
<listEntry value="507">@Strings.FldOwmDescShort</listEntry>
|
||||||
|
<listEntry value="502">@Strings.FldOwmDescLong</listEntry>
|
||||||
|
<listEntry value="501">@Strings.FldOwmHumidity</listEntry>
|
||||||
|
<listEntry value="506">@Strings.FldOwmPressure</listEntry>
|
||||||
|
<listEntry value="509">@Strings.FldOwmVisibility</listEntry>
|
||||||
|
<listEntry value="510">@Strings.FldOwmWind</listEntry>
|
||||||
|
<listEntry value="511">@Strings.FldOwmWindDeg</listEntry>
|
||||||
|
<listEntry value="518">@Strings.FldOwmWindDir</listEntry>
|
||||||
|
<listEntry value="512">@Strings.FldOwmGust</listEntry>
|
||||||
|
<listEntry value="513">@Strings.FldOwmRain</listEntry>
|
||||||
|
<listEntry value="532">@Strings.FldOwmSnow</listEntry>
|
||||||
|
<listEntry value="536">@Strings.FldOwmClouds</listEntry>
|
||||||
|
<listEntry value="537">@Strings.FldOwmPrecip</listEntry>
|
||||||
|
<listEntry value="530">@Strings.FldOwmDew</listEntry>
|
||||||
|
<listEntry value="534">@Strings.FldOwmTomorrow</listEntry>
|
||||||
|
<listEntry value="522">@Strings.FldOwmMinMaxTmrw</listEntry>
|
||||||
|
<listEntry value="535">@Strings.FldOwmDayAfter</listEntry>
|
||||||
|
<listEntry value="523">@Strings.FldOwmMinMaxD2</listEntry>
|
||||||
|
<listEntry value="516">@Strings.FldOwmCity</listEntry>
|
||||||
|
<listEntry value="517">@Strings.FldOwmUpdated</listEntry>
|
||||||
|
<listEntry value="703">@Strings.FldCustom1</listEntry>
|
||||||
|
<listEntry value="704">@Strings.FldCustom2</listEntry>
|
||||||
|
<listEntry value="705">@Strings.FldCustom3</listEntry>
|
||||||
|
</settingConfig>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting propertyKey="@Properties.BottomRight" title="@Strings.SettingBottomRight">
|
||||||
|
<settingConfig type="list">
|
||||||
|
<listEntry value="0">@Strings.FldOff</listEntry>
|
||||||
|
<listEntry value="860">@Strings.FldTime</listEntry>
|
||||||
|
<listEntry value="880">@Strings.FldUtcTime</listEntry>
|
||||||
|
<listEntry value="864">@Strings.FldAltTime1</listEntry>
|
||||||
|
<listEntry value="865">@Strings.FldAltTime2</listEntry>
|
||||||
|
<listEntry value="851">@Strings.FldDayMonthDay</listEntry>
|
||||||
|
<listEntry value="872">@Strings.FldDayDate</listEntry>
|
||||||
|
<listEntry value="852">@Strings.FldMonthDay</listEntry>
|
||||||
|
<listEntry value="855">@Strings.FldDayOfMonth</listEntry>
|
||||||
|
<listEntry value="870">@Strings.FldDayShort</listEntry>
|
||||||
|
<listEntry value="856">@Strings.FldDayFull</listEntry>
|
||||||
|
<listEntry value="853">@Strings.FldMonthName</listEntry>
|
||||||
|
<listEntry value="868">@Strings.FldMonthNum</listEntry>
|
||||||
|
<listEntry value="858">@Strings.FldMeridiem</listEntry>
|
||||||
|
<listEntry value="859">@Strings.FldWeekIso</listEntry>
|
||||||
|
<listEntry value="850">@Strings.FldSeconds</listEntry>
|
||||||
|
<listEntry value="869">@Strings.FldSeconds0</listEntry>
|
||||||
|
<listEntry value="873">@Strings.FldWeekCal</listEntry>
|
||||||
|
<listEntry value="1">@Strings.FldSteps</listEntry>
|
||||||
|
<listEntry value="14">@Strings.FldStepsRound</listEntry>
|
||||||
|
<listEntry value="707">@Strings.FldStepsToGo</listEntry>
|
||||||
|
<listEntry value="2">@Strings.FldCalories</listEntry>
|
||||||
|
<listEntry value="603">@Strings.FldActiveCal</listEntry>
|
||||||
|
<listEntry value="150">@Strings.FldActiveMin</listEntry>
|
||||||
|
<listEntry value="152">@Strings.FldActiveMod</listEntry>
|
||||||
|
<listEntry value="154">@Strings.FldActiveVig</listEntry>
|
||||||
|
<listEntry value="151">@Strings.FldActiveWeek</listEntry>
|
||||||
|
<listEntry value="3">@Strings.FldDistance</listEntry>
|
||||||
|
<listEntry value="4">@Strings.FldFloorsUp</listEntry>
|
||||||
|
<listEntry value="5">@Strings.FldFloorsDown</listEntry>
|
||||||
|
<listEntry value="6">@Strings.FldMetersUp</listEntry>
|
||||||
|
<listEntry value="9">@Strings.FldPushDist</listEntry>
|
||||||
|
<listEntry value="10">@Strings.FldPushes</listEntry>
|
||||||
|
<listEntry value="219">@Strings.FldStepsWeek</listEntry>
|
||||||
|
<listEntry value="220">@Strings.FldSteps7d</listEntry>
|
||||||
|
<listEntry value="211">@Strings.FldCalWeek</listEntry>
|
||||||
|
<listEntry value="212">@Strings.FldCal7d</listEntry>
|
||||||
|
<listEntry value="213">@Strings.FldDistWeek</listEntry>
|
||||||
|
<listEntry value="214">@Strings.FldDist7d</listEntry>
|
||||||
|
<listEntry value="203">@Strings.FldFloorsWeek</listEntry>
|
||||||
|
<listEntry value="204">@Strings.FldFloors7d</listEntry>
|
||||||
|
<listEntry value="200">@Strings.FldActiveMin7d</listEntry>
|
||||||
|
<listEntry value="618">@Strings.FldHeartRate</listEntry>
|
||||||
|
<listEntry value="276">@Strings.FldRestingHr</listEntry>
|
||||||
|
<listEntry value="12">@Strings.FldStress</listEntry>
|
||||||
|
<listEntry value="635">@Strings.FldPulseOx</listEntry>
|
||||||
|
<listEntry value="11">@Strings.FldRespiration</listEntry>
|
||||||
|
<listEntry value="623">@Strings.FldBodyBattery</listEntry>
|
||||||
|
<listEntry value="8">@Strings.FldMoveBar</listEntry>
|
||||||
|
<listEntry value="15">@Strings.FldMoveBarRev</listEntry>
|
||||||
|
<listEntry value="281">@Strings.FldWeight</listEntry>
|
||||||
|
<listEntry value="742">@Strings.FldBmi</listEntry>
|
||||||
|
<listEntry value="13">@Strings.FldRecovery</listEntry>
|
||||||
|
<listEntry value="278">@Strings.FldVo2Run</listEntry>
|
||||||
|
<listEntry value="277">@Strings.FldVo2Bike</listEntry>
|
||||||
|
<listEntry value="250">@Strings.FldBattery</listEntry>
|
||||||
|
<listEntry value="251">@Strings.FldBatteryDays</listEntry>
|
||||||
|
<listEntry value="257">@Strings.FldBatteryBoth</listEntry>
|
||||||
|
<listEntry value="253">@Strings.FldAlarms</listEntry>
|
||||||
|
<listEntry value="255">@Strings.FldNotifications</listEntry>
|
||||||
|
<listEntry value="254">@Strings.FldDoNotDisturb</listEntry>
|
||||||
|
<listEntry value="256">@Strings.FldBluetooth</listEntry>
|
||||||
|
<listEntry value="252">@Strings.FldSolar</listEntry>
|
||||||
|
<listEntry value="615">@Strings.FldAltitude</listEntry>
|
||||||
|
<listEntry value="616">@Strings.FldSeaPressure</listEntry>
|
||||||
|
<listEntry value="733">@Strings.FldAmbPressure</listEntry>
|
||||||
|
<listEntry value="712">@Strings.FldSunrise</listEntry>
|
||||||
|
<listEntry value="717">@Strings.FldSunset</listEntry>
|
||||||
|
<listEntry value="700">@Strings.FldNextSun</listEntry>
|
||||||
|
<listEntry value="731">@Strings.FldUntilSun</listEntry>
|
||||||
|
<listEntry value="706">@Strings.FldMoonAge</listEntry>
|
||||||
|
<listEntry value="732">@Strings.FldMoonLit</listEntry>
|
||||||
|
<listEntry value="710">@Strings.FldCivilAm</listEntry>
|
||||||
|
<listEntry value="709">@Strings.FldNauticalAm</listEntry>
|
||||||
|
<listEntry value="708">@Strings.FldAstroAm</listEntry>
|
||||||
|
<listEntry value="711">@Strings.FldBlueAm</listEntry>
|
||||||
|
<listEntry value="714">@Strings.FldGoldenAm</listEntry>
|
||||||
|
<listEntry value="715">@Strings.FldGoldenPm</listEntry>
|
||||||
|
<listEntry value="718">@Strings.FldBluePm</listEntry>
|
||||||
|
<listEntry value="719">@Strings.FldCivilPm</listEntry>
|
||||||
|
<listEntry value="720">@Strings.FldNauticalPm</listEntry>
|
||||||
|
<listEntry value="721">@Strings.FldAstroPm</listEntry>
|
||||||
|
<listEntry value="306">@Strings.FldTemp</listEntry>
|
||||||
|
<listEntry value="639">@Strings.FldTempHiLo</listEntry>
|
||||||
|
<listEntry value="300">@Strings.FldFeelsLike</listEntry>
|
||||||
|
<listEntry value="301">@Strings.FldTempHigh</listEntry>
|
||||||
|
<listEntry value="302">@Strings.FldTempLow</listEntry>
|
||||||
|
<listEntry value="305">@Strings.FldHumidity</listEntry>
|
||||||
|
<listEntry value="304">@Strings.FldPrecip</listEntry>
|
||||||
|
<listEntry value="308">@Strings.FldWindSpeed</listEntry>
|
||||||
|
<listEntry value="307">@Strings.FldWindBearing</listEntry>
|
||||||
|
<listEntry value="310">@Strings.FldWindDir</listEntry>
|
||||||
|
<listEntry value="309">@Strings.FldWeatherTime</listEntry>
|
||||||
|
<listEntry value="312">@Strings.FldPressureSea</listEntry>
|
||||||
|
<listEntry value="313">@Strings.FldUvi</listEntry>
|
||||||
|
<listEntry value="314">@Strings.FldVisibility</listEntry>
|
||||||
|
<listEntry value="315">@Strings.FldDewPoint</listEntry>
|
||||||
|
<listEntry value="500">@Strings.FldOwmTemp</listEntry>
|
||||||
|
<listEntry value="515">@Strings.FldOwmMinMax</listEntry>
|
||||||
|
<listEntry value="503">@Strings.FldOwmFeels</listEntry>
|
||||||
|
<listEntry value="504">@Strings.FldOwmMax</listEntry>
|
||||||
|
<listEntry value="505">@Strings.FldOwmMin</listEntry>
|
||||||
|
<listEntry value="507">@Strings.FldOwmDescShort</listEntry>
|
||||||
|
<listEntry value="502">@Strings.FldOwmDescLong</listEntry>
|
||||||
|
<listEntry value="501">@Strings.FldOwmHumidity</listEntry>
|
||||||
|
<listEntry value="506">@Strings.FldOwmPressure</listEntry>
|
||||||
|
<listEntry value="509">@Strings.FldOwmVisibility</listEntry>
|
||||||
|
<listEntry value="510">@Strings.FldOwmWind</listEntry>
|
||||||
|
<listEntry value="511">@Strings.FldOwmWindDeg</listEntry>
|
||||||
|
<listEntry value="518">@Strings.FldOwmWindDir</listEntry>
|
||||||
|
<listEntry value="512">@Strings.FldOwmGust</listEntry>
|
||||||
|
<listEntry value="513">@Strings.FldOwmRain</listEntry>
|
||||||
|
<listEntry value="532">@Strings.FldOwmSnow</listEntry>
|
||||||
|
<listEntry value="536">@Strings.FldOwmClouds</listEntry>
|
||||||
|
<listEntry value="537">@Strings.FldOwmPrecip</listEntry>
|
||||||
|
<listEntry value="530">@Strings.FldOwmDew</listEntry>
|
||||||
|
<listEntry value="534">@Strings.FldOwmTomorrow</listEntry>
|
||||||
|
<listEntry value="522">@Strings.FldOwmMinMaxTmrw</listEntry>
|
||||||
|
<listEntry value="535">@Strings.FldOwmDayAfter</listEntry>
|
||||||
|
<listEntry value="523">@Strings.FldOwmMinMaxD2</listEntry>
|
||||||
|
<listEntry value="516">@Strings.FldOwmCity</listEntry>
|
||||||
|
<listEntry value="517">@Strings.FldOwmUpdated</listEntry>
|
||||||
|
<listEntry value="703">@Strings.FldCustom1</listEntry>
|
||||||
|
<listEntry value="704">@Strings.FldCustom2</listEntry>
|
||||||
|
<listEntry value="705">@Strings.FldCustom3</listEntry>
|
||||||
|
</settingConfig>
|
||||||
|
</setting>
|
||||||
|
|
||||||
<setting propertyKey="@Properties.RingTR" title="@Strings.SettingRingTR">
|
<setting propertyKey="@Properties.RingTR" title="@Strings.SettingRingTR">
|
||||||
<settingConfig type="list">
|
<settingConfig type="list">
|
||||||
<listEntry value="0">@Strings.MetricSteps</listEntry>
|
<listEntry value="0">@Strings.MetricSteps</listEntry>
|
||||||
@@ -18,6 +539,8 @@
|
|||||||
<listEntry value="2">@Strings.MetricDistance</listEntry>
|
<listEntry value="2">@Strings.MetricDistance</listEntry>
|
||||||
<listEntry value="3">@Strings.MetricFloors</listEntry>
|
<listEntry value="3">@Strings.MetricFloors</listEntry>
|
||||||
<listEntry value="4">@Strings.MetricMoveBar</listEntry>
|
<listEntry value="4">@Strings.MetricMoveBar</listEntry>
|
||||||
|
<listEntry value="5">@Strings.MetricActiveMin</listEntry>
|
||||||
|
<listEntry value="6">@Strings.MetricBodyBattery</listEntry>
|
||||||
</settingConfig>
|
</settingConfig>
|
||||||
</setting>
|
</setting>
|
||||||
|
|
||||||
@@ -28,6 +551,8 @@
|
|||||||
<listEntry value="2">@Strings.MetricDistance</listEntry>
|
<listEntry value="2">@Strings.MetricDistance</listEntry>
|
||||||
<listEntry value="3">@Strings.MetricFloors</listEntry>
|
<listEntry value="3">@Strings.MetricFloors</listEntry>
|
||||||
<listEntry value="4">@Strings.MetricMoveBar</listEntry>
|
<listEntry value="4">@Strings.MetricMoveBar</listEntry>
|
||||||
|
<listEntry value="5">@Strings.MetricActiveMin</listEntry>
|
||||||
|
<listEntry value="6">@Strings.MetricBodyBattery</listEntry>
|
||||||
</settingConfig>
|
</settingConfig>
|
||||||
</setting>
|
</setting>
|
||||||
|
|
||||||
@@ -38,6 +563,8 @@
|
|||||||
<listEntry value="2">@Strings.MetricDistance</listEntry>
|
<listEntry value="2">@Strings.MetricDistance</listEntry>
|
||||||
<listEntry value="3">@Strings.MetricFloors</listEntry>
|
<listEntry value="3">@Strings.MetricFloors</listEntry>
|
||||||
<listEntry value="4">@Strings.MetricMoveBar</listEntry>
|
<listEntry value="4">@Strings.MetricMoveBar</listEntry>
|
||||||
|
<listEntry value="5">@Strings.MetricActiveMin</listEntry>
|
||||||
|
<listEntry value="6">@Strings.MetricBodyBattery</listEntry>
|
||||||
</settingConfig>
|
</settingConfig>
|
||||||
</setting>
|
</setting>
|
||||||
|
|
||||||
@@ -48,29 +575,42 @@
|
|||||||
<listEntry value="2">@Strings.MetricDistance</listEntry>
|
<listEntry value="2">@Strings.MetricDistance</listEntry>
|
||||||
<listEntry value="3">@Strings.MetricFloors</listEntry>
|
<listEntry value="3">@Strings.MetricFloors</listEntry>
|
||||||
<listEntry value="4">@Strings.MetricMoveBar</listEntry>
|
<listEntry value="4">@Strings.MetricMoveBar</listEntry>
|
||||||
|
<listEntry value="5">@Strings.MetricActiveMin</listEntry>
|
||||||
|
<listEntry value="6">@Strings.MetricBodyBattery</listEntry>
|
||||||
</settingConfig>
|
</settingConfig>
|
||||||
</setting>
|
</setting>
|
||||||
|
|
||||||
<setting propertyKey="@Properties.LeftTop" title="@Strings.SettingLeftTop">
|
|
||||||
|
<setting propertyKey="@Properties.TempUnit" title="@Strings.SettingTempUnit">
|
||||||
<settingConfig type="list">
|
<settingConfig type="list">
|
||||||
<listEntry value="0">@Strings.FieldWeather</listEntry>
|
<listEntry value="0">@Strings.TempUnitAuto</listEntry>
|
||||||
<listEntry value="1">@Strings.FieldCalories</listEntry>
|
<listEntry value="1">@Strings.TempUnitC</listEntry>
|
||||||
<listEntry value="2">@Strings.FieldSteps</listEntry>
|
<listEntry value="2">@Strings.TempUnitF</listEntry>
|
||||||
<listEntry value="3">@Strings.FieldDistance</listEntry>
|
|
||||||
<listEntry value="4">@Strings.FieldFloors</listEntry>
|
|
||||||
<listEntry value="5">@Strings.FieldBattery</listEntry>
|
|
||||||
</settingConfig>
|
</settingConfig>
|
||||||
</setting>
|
</setting>
|
||||||
|
|
||||||
<setting propertyKey="@Properties.RightTop" title="@Strings.SettingRightTop">
|
<setting propertyKey="@Properties.AltOffset1" title="@Strings.SettingAltOffset1">
|
||||||
<settingConfig type="list">
|
<settingConfig type="numeric" min="-720" max="840"/>
|
||||||
<listEntry value="0">@Strings.FieldWeather</listEntry>
|
</setting>
|
||||||
<listEntry value="1">@Strings.FieldCalories</listEntry>
|
|
||||||
<listEntry value="2">@Strings.FieldSteps</listEntry>
|
<setting propertyKey="@Properties.AltOffset2" title="@Strings.SettingAltOffset2">
|
||||||
<listEntry value="3">@Strings.FieldDistance</listEntry>
|
<settingConfig type="numeric" min="-720" max="840"/>
|
||||||
<listEntry value="4">@Strings.FieldFloors</listEntry>
|
</setting>
|
||||||
<listEntry value="5">@Strings.FieldBattery</listEntry>
|
|
||||||
</settingConfig>
|
<setting propertyKey="@Properties.OwmKey" title="@Strings.SettingOwmKey">
|
||||||
|
<settingConfig type="alphaNumeric" maxLength="40"/>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting propertyKey="@Properties.Custom1" title="@Strings.SettingCustom1">
|
||||||
|
<settingConfig type="alphaNumeric" maxLength="12"/>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting propertyKey="@Properties.Custom2" title="@Strings.SettingCustom2">
|
||||||
|
<settingConfig type="alphaNumeric" maxLength="12"/>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting propertyKey="@Properties.Custom3" title="@Strings.SettingCustom3">
|
||||||
|
<settingConfig type="alphaNumeric" maxLength="12"/>
|
||||||
</setting>
|
</setting>
|
||||||
|
|
||||||
<setting propertyKey="@Properties.ShowTop" title="@Strings.SettingShowTop">
|
<setting propertyKey="@Properties.ShowTop" title="@Strings.SettingShowTop">
|
||||||
|
|||||||
139
resources/strings/fields.xml
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<strings>
|
||||||
|
<!-- Field names shown in the settings picker. -->
|
||||||
|
<string id="FldOff">Off</string>
|
||||||
|
<string id="FldTime">Time</string>
|
||||||
|
<string id="FldUtcTime">UTC Time</string>
|
||||||
|
<string id="FldAltTime1">Alternate Time 1</string>
|
||||||
|
<string id="FldAltTime2">Alternate Time 2</string>
|
||||||
|
<string id="FldDayMonthDay">Day Month Day</string>
|
||||||
|
<string id="FldDayDate">Day Date</string>
|
||||||
|
<string id="FldMonthDay">Month Day</string>
|
||||||
|
<string id="FldDayOfMonth">Day of Month</string>
|
||||||
|
<string id="FldDayShort">Day (Tue)</string>
|
||||||
|
<string id="FldDayFull">Full Day (Tuesday)</string>
|
||||||
|
<string id="FldMonthName">Month (Jan)</string>
|
||||||
|
<string id="FldMonthNum">Month (6)</string>
|
||||||
|
<string id="FldMeridiem">AM/PM</string>
|
||||||
|
<string id="FldWeekIso">Week Number (ISO)</string>
|
||||||
|
<string id="FldSeconds">Seconds</string>
|
||||||
|
<string id="FldSeconds0">Seconds (09)</string>
|
||||||
|
<string id="FldWeekCal">Week Number</string>
|
||||||
|
<string id="FldSteps">Steps</string>
|
||||||
|
<string id="FldStepsRound">Steps Rounded</string>
|
||||||
|
<string id="FldStepsToGo">Steps to Go</string>
|
||||||
|
<string id="FldCalories">Calories</string>
|
||||||
|
<string id="FldActiveCal">Active Calories</string>
|
||||||
|
<string id="FldActiveMin">Active Minutes</string>
|
||||||
|
<string id="FldActiveMod">Active Min (Moderate)</string>
|
||||||
|
<string id="FldActiveVig">Active Min (Vigorous)</string>
|
||||||
|
<string id="FldActiveWeek">Active Minutes (Week)</string>
|
||||||
|
<string id="FldDistance">Distance</string>
|
||||||
|
<string id="FldFloorsUp">Floors Climbed</string>
|
||||||
|
<string id="FldFloorsDown">Floors Descended</string>
|
||||||
|
<string id="FldMetersUp">Distance Climbed</string>
|
||||||
|
<string id="FldPushDist">Push Distance</string>
|
||||||
|
<string id="FldPushes">Pushes</string>
|
||||||
|
<string id="FldStepsWeek">Steps (Week to Date)</string>
|
||||||
|
<string id="FldSteps7d">Steps (Last 7 Days)</string>
|
||||||
|
<string id="FldCalWeek">Calories (Week to Date)</string>
|
||||||
|
<string id="FldCal7d">Calories (Last 7 Days)</string>
|
||||||
|
<string id="FldDistWeek">Distance (Week to Date)</string>
|
||||||
|
<string id="FldDist7d">Distance (Last 7 Days)</string>
|
||||||
|
<string id="FldFloorsWeek">Floors (Week to Date)</string>
|
||||||
|
<string id="FldFloors7d">Floors (Last 7 Days)</string>
|
||||||
|
<string id="FldActiveMin7d">Active Minutes (7 Days)</string>
|
||||||
|
<string id="FldHeartRate">Heart Rate</string>
|
||||||
|
<string id="FldRestingHr">Resting Heart Rate</string>
|
||||||
|
<string id="FldStress">Stress Score</string>
|
||||||
|
<string id="FldPulseOx">Pulse Ox</string>
|
||||||
|
<string id="FldRespiration">Respiration Rate</string>
|
||||||
|
<string id="FldBodyBattery">Body Battery</string>
|
||||||
|
<string id="FldMoveBar">Move Bar</string>
|
||||||
|
<string id="FldMoveBarRev">Move Bar Reversed</string>
|
||||||
|
<string id="FldWeight">Weight</string>
|
||||||
|
<string id="FldBmi">BMI</string>
|
||||||
|
<string id="FldRecovery">Time to Recovery</string>
|
||||||
|
<string id="FldVo2Run">VO2 Max (Run)</string>
|
||||||
|
<string id="FldVo2Bike">VO2 Max (Bike)</string>
|
||||||
|
<string id="FldBattery">Battery</string>
|
||||||
|
<string id="FldBatteryDays">Battery in Days</string>
|
||||||
|
<string id="FldBatteryBoth">Battery / Days</string>
|
||||||
|
<string id="FldAlarms">Alarms</string>
|
||||||
|
<string id="FldNotifications">Notifications</string>
|
||||||
|
<string id="FldDoNotDisturb">Do Not Disturb</string>
|
||||||
|
<string id="FldBluetooth">Bluetooth</string>
|
||||||
|
<string id="FldSolar">Solar Intensity</string>
|
||||||
|
<string id="FldAltitude">Altitude</string>
|
||||||
|
<string id="FldSeaPressure">Sea Level Pressure</string>
|
||||||
|
<string id="FldAmbPressure">Ambient Pressure</string>
|
||||||
|
<string id="FldSunrise">Sunrise</string>
|
||||||
|
<string id="FldSunset">Sunset</string>
|
||||||
|
<string id="FldNextSun">Next Sun Event</string>
|
||||||
|
<string id="FldUntilSun">Time Until Sun Event</string>
|
||||||
|
<string id="FldMoonAge">Moon Age</string>
|
||||||
|
<string id="FldMoonLit">Moon Illumination</string>
|
||||||
|
<string id="FldCivilAm">Civil Twilight Start</string>
|
||||||
|
<string id="FldNauticalAm">Nautical Twilight Start</string>
|
||||||
|
<string id="FldAstroAm">Astronomical Twilight Start</string>
|
||||||
|
<string id="FldBlueAm">Blue Hour (AM)</string>
|
||||||
|
<string id="FldGoldenAm">Golden Hour (AM)</string>
|
||||||
|
<string id="FldGoldenPm">Golden Hour (PM)</string>
|
||||||
|
<string id="FldBluePm">Blue Hour (PM)</string>
|
||||||
|
<string id="FldCivilPm">Civil Twilight End</string>
|
||||||
|
<string id="FldNauticalPm">Nautical Twilight End</string>
|
||||||
|
<string id="FldAstroPm">Astronomical Twilight End</string>
|
||||||
|
<string id="FldTemp">Temperature</string>
|
||||||
|
<string id="FldTempHiLo">Temperature High/Low</string>
|
||||||
|
<string id="FldFeelsLike">Feels Like</string>
|
||||||
|
<string id="FldTempHigh">High Temperature</string>
|
||||||
|
<string id="FldTempLow">Low Temperature</string>
|
||||||
|
<string id="FldHumidity">Humidity</string>
|
||||||
|
<string id="FldPrecip">Precipitation %</string>
|
||||||
|
<string id="FldWindSpeed">Wind Speed</string>
|
||||||
|
<string id="FldWindBearing">Wind Bearing</string>
|
||||||
|
<string id="FldWindDir">Wind Direction</string>
|
||||||
|
<string id="FldWeatherTime">Weather Update Time</string>
|
||||||
|
<string id="FldPressureSea">Pressure (Sea Level)</string>
|
||||||
|
<string id="FldUvi">UV Index</string>
|
||||||
|
<string id="FldVisibility">Visibility</string>
|
||||||
|
<string id="FldDewPoint">Dew Point</string>
|
||||||
|
<string id="FldOwmTemp">OWM Temperature</string>
|
||||||
|
<string id="FldOwmMinMax">OWM Max/Min Temp</string>
|
||||||
|
<string id="FldOwmFeels">OWM Feels Like</string>
|
||||||
|
<string id="FldOwmMax">OWM Temp Max</string>
|
||||||
|
<string id="FldOwmMin">OWM Temp Min</string>
|
||||||
|
<string id="FldOwmDescShort">OWM Description</string>
|
||||||
|
<string id="FldOwmDescLong">OWM Description (Long)</string>
|
||||||
|
<string id="FldOwmHumidity">OWM Humidity</string>
|
||||||
|
<string id="FldOwmPressure">OWM Pressure</string>
|
||||||
|
<string id="FldOwmVisibility">OWM Visibility</string>
|
||||||
|
<string id="FldOwmWind">OWM Wind Speed</string>
|
||||||
|
<string id="FldOwmWindDeg">OWM Wind Degrees</string>
|
||||||
|
<string id="FldOwmWindDir">OWM Wind Direction</string>
|
||||||
|
<string id="FldOwmGust">OWM Wind Gust</string>
|
||||||
|
<string id="FldOwmRain">OWM Rain 1h</string>
|
||||||
|
<string id="FldOwmSnow">OWM Snow 1h</string>
|
||||||
|
<string id="FldOwmClouds">OWM Cloud Cover</string>
|
||||||
|
<string id="FldOwmPrecip">OWM Precipitation %</string>
|
||||||
|
<string id="FldOwmDew">OWM Dew Point</string>
|
||||||
|
<string id="FldOwmTomorrow">OWM Temp Tomorrow</string>
|
||||||
|
<string id="FldOwmMinMaxTmrw">OWM Min/Max Tomorrow</string>
|
||||||
|
<string id="FldOwmDayAfter">OWM Temp Day After</string>
|
||||||
|
<string id="FldOwmMinMaxD2">OWM Min/Max Day After</string>
|
||||||
|
<string id="FldOwmCity">OWM City Name</string>
|
||||||
|
<string id="FldOwmUpdated">OWM Update Time</string>
|
||||||
|
<string id="FldCustom1">Custom Text 1</string>
|
||||||
|
<string id="FldCustom2">Custom Text 2</string>
|
||||||
|
<string id="FldCustom3">Custom Text 3</string>
|
||||||
|
|
||||||
|
<!-- On-face captions, in FieldTable.IDS order. -->
|
||||||
|
<string id="FieldLabels">,TIME,UTC,TZ1,TZ2,DATE,DATE,DATE,DAY,DAY,DAY,MON,MON,,WEEK,SEC,SEC,WEEK,STEP,STEP,TO GO,KCAL,ACAL,MIN,MOD,VIG,WEEK,DST,FLR,DESC,ASC,PUSH,PUSH,WEEK,7D,WEEK,7D,WEEK,7D,WEEK,7D,7D,HR,RHR,STRS,SPO2,RESP,BODY,MOVE,MOVE,WT,BMI,RCVR,VO2,VO2,BATT,DAYS,BATT,ALRM,MSG,DND,BT,SOLR,ALT,hPa,hPa,RISE,SET,SUN,SUN,MOON,MOON,CIVIL,NAUT,ASTR,BLUE,GOLD,GOLD,BLUE,CIVIL,NAUT,ASTR,TEMP,HI/LO,FEEL,HIGH,LOW,HUM,RAIN,WIND,WIND,WIND,WX,hPa,UVI,VIS,DEW,TEMP,HI/LO,FEEL,HIGH,LOW,WX,WX,HUM,hPa,VIS,WIND,WIND,WIND,GUST,RAIN,SNOW,CLD,RAIN,DEW,TMRW,TMRW,D+2,D+2,CITY,OWM,,,</string>
|
||||||
|
|
||||||
|
<string id="MetricSteps">Steps</string>
|
||||||
|
<string id="MetricCalories">Calories</string>
|
||||||
|
<string id="MetricDistance">Distance</string>
|
||||||
|
<string id="MetricFloors">Floors</string>
|
||||||
|
<string id="MetricMoveBar">Move Bar</string>
|
||||||
|
<string id="MetricActiveMin">Active Minutes</string>
|
||||||
|
<string id="MetricBodyBattery">Body Battery</string>
|
||||||
|
</strings>
|
||||||
@@ -6,12 +6,25 @@
|
|||||||
<string id="SettingRingTL">Ring · Top-Left</string>
|
<string id="SettingRingTL">Ring · Top-Left</string>
|
||||||
<string id="SettingRingBR">Ring · Bottom-Right</string>
|
<string id="SettingRingBR">Ring · Bottom-Right</string>
|
||||||
<string id="SettingRingBL">Ring · Bottom-Left</string>
|
<string id="SettingRingBL">Ring · Bottom-Left</string>
|
||||||
<string id="SettingLeftTop">Top-Left Complication</string>
|
<string id="SettingLeftTop">Top-Left Field</string>
|
||||||
<string id="SettingRightTop">Top-Right Complication</string>
|
<string id="SettingRightTop">Top-Right Field</string>
|
||||||
|
<string id="SettingBottomLeft">Bottom-Left Field</string>
|
||||||
|
<string id="SettingBottomRight">Bottom-Right Field</string>
|
||||||
|
<string id="SettingTempUnit">Temperature Unit</string>
|
||||||
|
<string id="SettingAltOffset1">Alternate Time 1 (minutes from local)</string>
|
||||||
|
<string id="SettingAltOffset2">Alternate Time 2 (minutes from local)</string>
|
||||||
|
<string id="SettingOwmKey">OpenWeatherMap API Key</string>
|
||||||
|
<string id="SettingCustom1">Custom Text 1</string>
|
||||||
|
<string id="SettingCustom2">Custom Text 2</string>
|
||||||
|
<string id="SettingCustom3">Custom Text 3</string>
|
||||||
<string id="SettingShowTop">Show Top Row</string>
|
<string id="SettingShowTop">Show Top Row</string>
|
||||||
<string id="SettingShowBand">Show Date Band</string>
|
<string id="SettingShowBand">Show Date Band</string>
|
||||||
<string id="SettingShowBottom">Show Bottom Row</string>
|
<string id="SettingShowBottom">Show Bottom Row</string>
|
||||||
|
|
||||||
|
<string id="TempUnitAuto">Follow watch</string>
|
||||||
|
<string id="TempUnitC">Celsius (°C)</string>
|
||||||
|
<string id="TempUnitF">Fahrenheit (°F)</string>
|
||||||
|
|
||||||
<string id="ThemeEmber">1 · Ember</string>
|
<string id="ThemeEmber">1 · Ember</string>
|
||||||
<string id="ThemeAurora">2 · Aurora</string>
|
<string id="ThemeAurora">2 · Aurora</string>
|
||||||
<string id="ThemeBrass">3 · Brass</string>
|
<string id="ThemeBrass">3 · Brass</string>
|
||||||
@@ -20,16 +33,9 @@
|
|||||||
<string id="ThemeAcid">6 · Acid</string>
|
<string id="ThemeAcid">6 · Acid</string>
|
||||||
<string id="ThemeKelp">7 · Kelp</string>
|
<string id="ThemeKelp">7 · Kelp</string>
|
||||||
|
|
||||||
<string id="MetricSteps">Steps</string>
|
<!-- On-face wording. Comma-separated lists are split at runtime. -->
|
||||||
<string id="MetricCalories">Calories</string>
|
<string id="DowNames">SUN,MON,TUE,WED,THU,FRI,SAT</string>
|
||||||
<string id="MetricDistance">Distance</string>
|
<string id="MonNames">JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC</string>
|
||||||
<string id="MetricFloors">Floors</string>
|
<string id="DateFormat">$1$ $2$</string>
|
||||||
<string id="MetricMoveBar">Move Bar</string>
|
<string id="Meridiem">AM,PM</string>
|
||||||
|
|
||||||
<string id="FieldWeather">Weather</string>
|
|
||||||
<string id="FieldCalories">Calories</string>
|
|
||||||
<string id="FieldSteps">Steps</string>
|
|
||||||
<string id="FieldDistance">Distance</string>
|
|
||||||
<string id="FieldFloors">Floors</string>
|
|
||||||
<string id="FieldBattery">Battery</string>
|
|
||||||
</strings>
|
</strings>
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
import Toybox.Application;
|
import Toybox.Application;
|
||||||
|
import Toybox.Background;
|
||||||
import Toybox.Lang;
|
import Toybox.Lang;
|
||||||
|
import Toybox.System;
|
||||||
|
import Toybox.Time;
|
||||||
import Toybox.WatchUi;
|
import Toybox.WatchUi;
|
||||||
|
import Owm;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 应用入口
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 表盘没有菜单也拿不到输入,所以这个壳子只做三件事:
|
||||||
|
// 1. 交出 view
|
||||||
|
// 2. 设置变更时请求重绘
|
||||||
|
// 3. 管理拉取 OpenWeatherMap 的后台服务
|
||||||
|
// ============================================================================
|
||||||
class Fenix8V3App extends Application.AppBase {
|
class Fenix8V3App extends Application.AppBase {
|
||||||
|
|
||||||
|
// OWM 免费档的调用额度很宽裕,但表盘每分钟才重绘一次、数据本身也是小时级的,
|
||||||
|
// 拉太勤只是白白唤醒射频费电。半小时是个合适的折中。
|
||||||
|
const OWM_PERIOD_MIN = 30;
|
||||||
|
|
||||||
function initialize() {
|
function initialize() {
|
||||||
AppBase.initialize();
|
AppBase.initialize();
|
||||||
}
|
}
|
||||||
@@ -14,13 +30,55 @@ class Fenix8V3App extends Application.AppBase {
|
|||||||
function onStop(state as Dictionary?) as Void {
|
function onStop(state as Dictionary?) as Void {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⚠️ 定时器的注册必须放在 getInitialView 里,不能放 onStart。
|
||||||
|
// 因为 onStart **在后台服务进程里也会被调用一次**,而在后台进程里调
|
||||||
|
// registerForTemporalEvent 会抛 Out of Bounds。
|
||||||
|
// getInitialView 只在前台进程运行,是安全的位置。
|
||||||
function getInitialView() as [Views] or [Views, InputDelegates] {
|
function getInitialView() as [Views] or [Views, InputDelegates] {
|
||||||
|
scheduleWeather();
|
||||||
return [new Fenix8V3View()];
|
return [new Fenix8V3View()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getServiceDelegate() as [System.ServiceDelegate] {
|
||||||
|
return [new Fenix8V3Background()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有用户真的填了 API key 才注册定时器 —— 没填就没什么可拉的,
|
||||||
|
// 注册了纯属浪费电。反过来,用户把 key 清空时也要及时注销。
|
||||||
|
function scheduleWeather() as Void {
|
||||||
|
if (!(Background has :registerForTemporalEvent)) { return; }
|
||||||
|
// ⚠️ deleteTemporalEvent 在「本来就没注册」时会抛 Out of Bounds,
|
||||||
|
// 而这个错误是 VM 级的,**try/catch 抓不住**。
|
||||||
|
// 所以这里不去问系统当前状态,而是自己在 Storage 里记一个标志位。
|
||||||
|
// 重复注册本身是无害的(只是重置周期),所以只在状态真的变化时动手。
|
||||||
|
var want = Owm.enabled();
|
||||||
|
var have = Application.Storage.getValue("bgOn");
|
||||||
|
have = (have != null) && (have as Boolean);
|
||||||
|
if (want == have) { return; }
|
||||||
|
if (want) {
|
||||||
|
Background.registerForTemporalEvent(
|
||||||
|
new Time.Duration(OWM_PERIOD_MIN * 60));
|
||||||
|
} else {
|
||||||
|
Background.deleteTemporalEvent();
|
||||||
|
}
|
||||||
|
Application.Storage.setValue("bgOn", want);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手机把新设置推下来时触发。
|
||||||
|
// view 每次绘制都是现读设置,所以重绘一下就够了;
|
||||||
|
// 但天气定时器要跟着新的 key 状态启停,所以先过一遍 scheduleWeather。
|
||||||
function onSettingsChanged() as Void {
|
function onSettingsChanged() as Void {
|
||||||
|
scheduleWeather();
|
||||||
WatchUi.requestUpdate();
|
WatchUi.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 后台服务把结果通过这里交回前台。存进 Storage 供表盘读取,然后重绘。
|
||||||
|
function onBackgroundData(data as Application.PersistableType) as Void {
|
||||||
|
if (data != null) {
|
||||||
|
Application.Storage.setValue(Owm.STORE, data);
|
||||||
|
WatchUi.requestUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getApp() as Fenix8V3App {
|
function getApp() as Fenix8V3App {
|
||||||
|
|||||||
167
source/Fenix8V3Background.mc
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import Toybox.Lang;
|
||||||
|
import Toybox.Application;
|
||||||
|
import Toybox.Background;
|
||||||
|
import Toybox.Communications;
|
||||||
|
import Toybox.System;
|
||||||
|
import Toybox.Time;
|
||||||
|
import Toybox.Position;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OpenWeatherMap —— 后台拉取端
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 表盘进程绝大多数时间没在运行,onUpdate 又必须够快,不可能在绘制路径里发
|
||||||
|
// HTTP 请求。Connect IQ 的方案是**后台服务**:系统按定时器唤醒一个独立进程,
|
||||||
|
// 在里面发请求,把结果通过 Background.exit() 交回前台,前台再写进 Storage。
|
||||||
|
//
|
||||||
|
// 每次唤醒发两个请求:
|
||||||
|
// 1. 当前天气 /data/2.5/weather
|
||||||
|
// 2. 短期预报 /data/2.5/forecast?cnt=16(约 48 小时,够算明日/后日高低温)
|
||||||
|
//
|
||||||
|
// ⚠️ 预报接口必须带 cnt 限制条数。后台进程的内存预算非常小(约 32KB),
|
||||||
|
// 完整的 5 天 / 3 小时预报解析出来能有几十 KB,会直接爆掉。
|
||||||
|
//
|
||||||
|
// 响应会被**压平成一个扁平字典**再交回去,键名是短名("temp"/"hi1"…),
|
||||||
|
// 与 Owm.get() 的取值一一对应 —— 这样表盘侧永远不用扛一棵 JSON 树。
|
||||||
|
// ============================================================================
|
||||||
|
(:background)
|
||||||
|
class Fenix8V3Background extends System.ServiceDelegate {
|
||||||
|
|
||||||
|
private var mResult as Dictionary = {};
|
||||||
|
private var mPending = 0;
|
||||||
|
|
||||||
|
function initialize() {
|
||||||
|
ServiceDelegate.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统按定时器唤醒后台进程时调用。
|
||||||
|
// 注意这里是**独立进程**,内存预算比前台小得多,而且不能碰 UI。
|
||||||
|
function onTemporalEvent() as Void {
|
||||||
|
if (!Owm.enabled()) {
|
||||||
|
Background.exit(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var lat = Application.Storage.getValue("lastLat");
|
||||||
|
var lon = Application.Storage.getValue("lastLon");
|
||||||
|
if (lat == null || lon == null) {
|
||||||
|
// 表盘一拿到位置就会存进 Storage(见 Fields.Ctx.where)。
|
||||||
|
// 在那之前没有坐标可问,直接退出。
|
||||||
|
Background.exit(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mResult = {};
|
||||||
|
mPending = 2;
|
||||||
|
// 一律用 units=metric 请求,单位换算统一放到 Fields 里按用户设置做,
|
||||||
|
// 这样切换单位不用重新联网。
|
||||||
|
var common = {"lat" => lat, "lon" => lon,
|
||||||
|
"appid" => Owm.key(), "units" => "metric"};
|
||||||
|
var opts = {
|
||||||
|
:method => Communications.HTTP_REQUEST_METHOD_GET,
|
||||||
|
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON
|
||||||
|
};
|
||||||
|
Communications.makeWebRequest(
|
||||||
|
"https://api.openweathermap.org/data/2.5/weather",
|
||||||
|
common, opts, method(:onCurrent));
|
||||||
|
var fc = {"lat" => lat, "lon" => lon, "appid" => Owm.key(),
|
||||||
|
"units" => "metric", "cnt" => 16};
|
||||||
|
Communications.makeWebRequest(
|
||||||
|
"https://api.openweathermap.org/data/2.5/forecast",
|
||||||
|
fc, opts, method(:onForecast));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前天气的回调。OWM 的 JSON 是嵌套的,逐层取值时每层都要判空 ——
|
||||||
|
// 缺字段是常态(比如没下雨就没有 rain 节点)。
|
||||||
|
function onCurrent(code as Number, body as Dictionary?) as Void {
|
||||||
|
if (code == 200 && body != null) {
|
||||||
|
var main = body.get("main") as Dictionary?;
|
||||||
|
if (main != null) {
|
||||||
|
put("temp", main.get("temp"));
|
||||||
|
put("feels", main.get("feels_like"));
|
||||||
|
put("tmin", main.get("temp_min"));
|
||||||
|
put("tmax", main.get("temp_max"));
|
||||||
|
put("pressure", main.get("pressure"));
|
||||||
|
put("humidity", main.get("humidity"));
|
||||||
|
}
|
||||||
|
put("visibility", body.get("visibility"));
|
||||||
|
var wind = body.get("wind") as Dictionary?;
|
||||||
|
if (wind != null) {
|
||||||
|
put("wind", wind.get("speed"));
|
||||||
|
put("winddeg", wind.get("deg"));
|
||||||
|
put("gust", wind.get("gust"));
|
||||||
|
}
|
||||||
|
var clouds = body.get("clouds") as Dictionary?;
|
||||||
|
if (clouds != null) { put("clouds", clouds.get("all")); }
|
||||||
|
var rain = body.get("rain") as Dictionary?;
|
||||||
|
if (rain != null) { put("rain", rain.get("1h")); }
|
||||||
|
var snow = body.get("snow") as Dictionary?;
|
||||||
|
if (snow != null) { put("snow", snow.get("1h")); }
|
||||||
|
var wx = body.get("weather") as Array?;
|
||||||
|
if (wx != null && wx.size() > 0) {
|
||||||
|
var w0 = wx[0] as Dictionary;
|
||||||
|
put("short", w0.get("main"));
|
||||||
|
put("long", w0.get("description"));
|
||||||
|
}
|
||||||
|
put("city", body.get("name"));
|
||||||
|
put("dt", Time.now().value());
|
||||||
|
}
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预报返回的是 3 小时粒度的列表。这里按「距今天几天」把条目分到
|
||||||
|
// 明天 / 后天两个桶里,各自取最高最低温;降水概率取明天的最大值。
|
||||||
|
function onForecast(code as Number, body as Dictionary?) as Void {
|
||||||
|
if (code == 200 && body != null) {
|
||||||
|
var list = body.get("list") as Array?;
|
||||||
|
if (list != null) {
|
||||||
|
var today = dayNumber(Time.now().value());
|
||||||
|
var lo1 = null; var hi1 = null; var lo2 = null; var hi2 = null;
|
||||||
|
var pop1 = null;
|
||||||
|
var arr = list;
|
||||||
|
for (var i = 0; i < arr.size(); i++) {
|
||||||
|
var e = arr[i] as Dictionary;
|
||||||
|
var dt = e.get("dt");
|
||||||
|
if (dt == null) { continue; }
|
||||||
|
var day = dayNumber(dt as Number) - today;
|
||||||
|
var m = e.get("main") as Dictionary?;
|
||||||
|
if (m == null) { continue; }
|
||||||
|
var tmin = m.get("temp_min");
|
||||||
|
var tmax = m.get("temp_max");
|
||||||
|
if (day == 1) {
|
||||||
|
if (lo1 == null || tmin < lo1) { lo1 = tmin; }
|
||||||
|
if (hi1 == null || tmax > hi1) { hi1 = tmax; }
|
||||||
|
var pop = e.get("pop");
|
||||||
|
if (pop != null && (pop1 == null || pop > pop1)) { pop1 = pop; }
|
||||||
|
} else if (day == 2) {
|
||||||
|
if (lo2 == null || tmin < lo2) { lo2 = tmin; }
|
||||||
|
if (hi2 == null || tmax > hi2) { hi2 = tmax; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put("lo1", lo1); put("hi1", hi1);
|
||||||
|
put("lo2", lo2); put("hi2", hi2);
|
||||||
|
put("pop", pop1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 距 Unix 纪元的整天数,用来给预报条目分桶。
|
||||||
|
function dayNumber(unix as Number) as Number {
|
||||||
|
return unix / 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只写入非空值,免得用 null 覆盖字典里已有的内容。
|
||||||
|
function put(k as String, v) as Void {
|
||||||
|
if (v != null) { mResult.put(k, v); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两个请求都回来了才算一轮完整数据,谁最后完成谁负责退出服务。
|
||||||
|
// 一条都没拿到就传 null,避免用空字典覆盖掉上一次的好数据。
|
||||||
|
function done() as Void {
|
||||||
|
mPending--;
|
||||||
|
if (mPending > 0) { return; }
|
||||||
|
if (mResult.size() == 0) {
|
||||||
|
Background.exit(null);
|
||||||
|
} else {
|
||||||
|
Background.exit(mResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,387 +1,471 @@
|
|||||||
import Toybox.Application;
|
|
||||||
import Toybox.Graphics;
|
import Toybox.Graphics;
|
||||||
import Toybox.Lang;
|
import Toybox.Lang;
|
||||||
import Toybox.Math;
|
import Toybox.Math;
|
||||||
import Toybox.ActivityMonitor;
|
import Toybox.ActivityMonitor;
|
||||||
import Toybox.Weather;
|
import Toybox.SensorHistory;
|
||||||
import Toybox.System;
|
import Toybox.System;
|
||||||
import Toybox.Time;
|
import Toybox.Time;
|
||||||
import Toybox.Time.Gregorian;
|
import Toybox.Time.Gregorian;
|
||||||
import Toybox.WatchUi;
|
import Toybox.WatchUi;
|
||||||
import Themes;
|
import Themes;
|
||||||
|
import Fields;
|
||||||
|
import Layout;
|
||||||
|
import Icons;
|
||||||
|
import Settings;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 表盘主体
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 负责:进度环、盖在环上的各个元素、以及 AMOLED 常亮时的简化版画面。
|
||||||
|
//
|
||||||
|
// 分工约定(改代码前先看清楚该动哪个文件):
|
||||||
|
// 位置尺寸 → Layout.mc
|
||||||
|
// 数据取值 → Fields.mc
|
||||||
|
// 配色 → Themes.mc(自动生成)
|
||||||
|
// 设置读取 → Settings.mc
|
||||||
|
// 图标 → Icons.mc
|
||||||
|
// 留在本文件里的只有「按什么顺序画」。
|
||||||
|
// ============================================================================
|
||||||
class Fenix8V3View extends WatchUi.WatchFace {
|
class Fenix8V3View extends WatchUi.WatchFace {
|
||||||
|
|
||||||
// ----------------------------------------------------------------- utils
|
// 常亮显示状态。
|
||||||
function getSetting(name as String, def as Number) as Number {
|
// AMOLED 屏在息屏常亮时必须大幅减少点亮像素(Garmin 上架审核会卡这一条,
|
||||||
var v = Application.Properties.getValue(name);
|
// 而且不这么做既费电又有烧屏风险);MIP 屏不受影响,照常全量绘制。
|
||||||
if (v == null) { return def; }
|
private var mSleeping = false;
|
||||||
return v as Number;
|
private var mBurnIn = false;
|
||||||
|
|
||||||
|
function initialize() {
|
||||||
|
WatchFace.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBool(name as String, def as Boolean) as Boolean {
|
// 进入低功耗(息屏)。AMOLED 机型从这一刻起改画简化版面。
|
||||||
var v = Application.Properties.getValue(name);
|
function onEnterSleep() as Void {
|
||||||
if (v == null) { return def; }
|
mSleeping = true;
|
||||||
return v as Boolean;
|
WatchUi.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentTheme() as Dictionary {
|
// 抬腕唤醒,恢复完整版面。
|
||||||
var idx = getSetting("Theme", 1); // 1..7
|
function onExitSleep() as Void {
|
||||||
var i = idx - 1;
|
mSleeping = false;
|
||||||
if (i < 0) { i = 0; }
|
WatchUi.requestUpdate();
|
||||||
if (i > 6) { i = 0; }
|
|
||||||
var id = Themes.ORDER[i];
|
|
||||||
return Themes.THEMES[id] as Dictionary;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function lerpColor(c1 as Number, c2 as Number, f as Float) as Number {
|
// 表盘加载时调一次;如果 onUpdate 发现缓存失效(分辨率变了,或资源还没
|
||||||
var r1 = (c1 & 0xFF0000) >> 16;
|
// 加载好)会再调一次。所有「一次性、开销大」的准备工作都放这里。
|
||||||
var g1 = (c1 & 0x00FF00) >> 8;
|
function onLayout(dc as Dc) as Void {
|
||||||
var b1 = (c1 & 0x0000FF);
|
Layout.init(dc);
|
||||||
var r2 = (c2 & 0xFF0000) >> 16;
|
Fields.loadStrings();
|
||||||
var g2 = (c2 & 0x00FF00) >> 8;
|
Icons.load();
|
||||||
var b2 = (c2 & 0x0000FF);
|
var ds = System.getDeviceSettings();
|
||||||
var r = (r1 + ((r2 - r1) * f).toNumber()).toNumber();
|
mBurnIn = (ds != null) && (ds has :requiresBurnInProtection)
|
||||||
var g = (g1 + ((g2 - g1) * f).toNumber()).toNumber();
|
&& ds.requiresBurnInProtection;
|
||||||
var b = (b1 + ((b2 - b1) * f).toNumber()).toNumber();
|
|
||||||
return (r << 16) | (g << 8) | b;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// interpolate a gradient stop array [[angle,color],...] (angles ascending)
|
// ------------------------------- 进度环 -------------------------------
|
||||||
function lerpStops(stops as Array<Array<Number>>, t as Number) as Number {
|
// 计算四段弧各自的进度,0..1。
|
||||||
var n = stops.size();
|
// ⚠️ 这里的 idx 是**进度环自己的一张短表**(见 Settings.ringTR 的注释),
|
||||||
if (n == 0) { return 0x888888; }
|
// 和数据位那套 125 项的字段编号完全是两回事,别混。
|
||||||
var first = stops[0];
|
function ringProgress(idx as Number, info as ActivityMonitor.Info?) as Float {
|
||||||
var last = stops[n - 1];
|
|
||||||
if (t <= first[0]) { return first[1]; }
|
|
||||||
if (t >= last[0]) { return last[1]; }
|
|
||||||
for (var i = 0; i < n - 1; i++) {
|
|
||||||
var a = stops[i];
|
|
||||||
var b = stops[i + 1];
|
|
||||||
if (t >= a[0] && t <= b[0]) {
|
|
||||||
var f = ((t - a[0]) / (b[0] - a[0])).toFloat();
|
|
||||||
return lerpColor(a[1], b[1], f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return last[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// progress 0..1 for a metric index
|
|
||||||
function metricProgress(idx as Number, info as ActivityMonitor.Info) as Float {
|
|
||||||
if (info == null) { return 0.0f; }
|
if (info == null) { return 0.0f; }
|
||||||
var p = 0.0f;
|
var p = 0.0f;
|
||||||
if (idx == 0) { // Steps
|
if (idx == 0) { // 步数:对每日目标
|
||||||
if (info.stepGoal != null && info.stepGoal > 0 && info.steps != null) {
|
if (info.stepGoal != null && info.stepGoal > 0 && info.steps != null) {
|
||||||
p = info.steps.toFloat() / info.stepGoal.toFloat();
|
p = info.steps.toFloat() / info.stepGoal.toFloat();
|
||||||
}
|
}
|
||||||
} else if (idx == 1) { // Calories
|
} else if (idx == 1) { // 卡路里:固定 2000 kcal 为满
|
||||||
if (info.calories != null) { p = info.calories.toFloat() / 2000.0f; }
|
if (info.calories != null) { p = info.calories.toFloat() / 2000.0f; }
|
||||||
} else if (idx == 2) { // Distance (cm -> 10 km cap)
|
} else if (idx == 2) { // 距离:10 km 为满(distance 单位是厘米)
|
||||||
if (info.distance != null) { p = info.distance.toFloat() / 1000000.0f; }
|
if (info.distance != null) { p = info.distance.toFloat() / 1000000.0f; }
|
||||||
} else if (idx == 3) { // Floors
|
} else if (idx == 3) { // 楼层:对每日目标,取不到目标就按 10 层
|
||||||
if (info.floorsClimbed != null) {
|
if (info.floorsClimbed != null) {
|
||||||
var goal = (info.floorsClimbedGoal != null && info.floorsClimbedGoal > 0)
|
var goal = (info.floorsClimbedGoal != null && info.floorsClimbedGoal > 0)
|
||||||
? info.floorsClimbedGoal.toFloat() : 10.0f;
|
? info.floorsClimbedGoal.toFloat() : 10.0f;
|
||||||
p = info.floorsClimbed.toFloat() / goal;
|
p = info.floorsClimbed.toFloat() / goal;
|
||||||
}
|
}
|
||||||
} else if (idx == 4) { // Move Bar
|
} else if (idx == 5) { // 活动分钟:对**每周**目标
|
||||||
if (info.moveBarLevel != null) { p = info.moveBarLevel.toFloat(); }
|
if ((info has :activeMinutesWeek) && info.activeMinutesWeek != null
|
||||||
|
&& (info has :activeMinutesWeekGoal)
|
||||||
|
&& info.activeMinutesWeekGoal != null
|
||||||
|
&& info.activeMinutesWeekGoal > 0) {
|
||||||
|
p = info.activeMinutesWeek.total.toFloat()
|
||||||
|
/ info.activeMinutesWeekGoal.toFloat();
|
||||||
|
}
|
||||||
|
} else if (idx == 6) { // 身体电量:本身就是 0..100
|
||||||
|
var bb = Fields.latest(SensorHistory.getBodyBatteryHistory({:period => 1}));
|
||||||
|
if (bb != null) { p = bb.toFloat() / 100.0; }
|
||||||
|
} else if (idx == 4) {
|
||||||
|
// ⚠️ 动动条是**整数等级**(0..MOVE_BAR_LEVEL_MAX,通常是 5),
|
||||||
|
// 不是 0..1 的比例。初版直接当比例用,结果等级≥1 就被钳成满环,
|
||||||
|
// 中间状态全丢了。
|
||||||
|
if (info.moveBarLevel != null) {
|
||||||
|
var span = (ActivityMonitor.MOVE_BAR_LEVEL_MAX
|
||||||
|
- ActivityMonitor.MOVE_BAR_LEVEL_MIN).toFloat();
|
||||||
|
if (span > 0) {
|
||||||
|
p = (info.moveBarLevel - ActivityMonitor.MOVE_BAR_LEVEL_MIN) / span;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (p > 1.0f) { p = 1.0f; }
|
if (p > 1.0f) { p = 1.0f; }
|
||||||
if (p < 0.0f) { p = 0.0f; }
|
if (p < 0.0f) { p = 0.0f; }
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
// value string for a top complication type
|
// 画一根刻度:环形扇区,用四点多边形填充。
|
||||||
function complicationValue(typeIdx as Number, info as ActivityMonitor.Info,
|
// 扇区两条弧边其实是曲线,但 3° 弧的弓高不到 0.1 像素,用直边完全够精确,
|
||||||
weather as Weather.CurrentConditions, stats as System.Stats) as String {
|
// 而 fillPolygon 比 drawArc 行为更确定(各机型的 drawArc 端点样式不一致)。
|
||||||
if (typeIdx == 0) { // Weather temp
|
function tick(dc as Dc, ang as Float, hw as Float) as Void {
|
||||||
if (weather != null && weather.temperature != null) {
|
var a0 = ang - hw;
|
||||||
return weather.temperature.format("%d") + "\u00B0";
|
var a1 = ang + hw;
|
||||||
}
|
var ro = Layout.R_OUT * Layout.scale;
|
||||||
return "--";
|
var ri = Layout.R_IN * Layout.scale;
|
||||||
} else if (typeIdx == 1) { // Calories
|
dc.fillPolygon([[Layout.polarX(a0, ro), Layout.polarY(a0, ro)],
|
||||||
return info != null && info.calories != null ? info.calories.format("%d") : "0";
|
[Layout.polarX(a1, ro), Layout.polarY(a1, ro)],
|
||||||
} else if (typeIdx == 2) { // Steps
|
[Layout.polarX(a1, ri), Layout.polarY(a1, ri)],
|
||||||
return info != null && info.steps != null ? info.steps.format("%d") : "0";
|
[Layout.polarX(a0, ri), Layout.polarY(a0, ri)]]);
|
||||||
} else if (typeIdx == 3) { // Distance km
|
|
||||||
if (info != null && info.distance != null) {
|
|
||||||
return (info.distance.toFloat() / 100000.0).format("%.1f");
|
|
||||||
}
|
|
||||||
return "0.0";
|
|
||||||
} else if (typeIdx == 4) { // Floors
|
|
||||||
return info != null && info.floorsClimbed != null ? info.floorsClimbed.format("%d") : "0";
|
|
||||||
} else { // Battery
|
|
||||||
var pct = 0;
|
|
||||||
if (stats != null && stats.battery != null) { pct = (stats.battery * 100.0).toNumber(); }
|
|
||||||
return pct.format("%d") + "%";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------- ring
|
// 通用画弧。点亮方向与设计稿一致:
|
||||||
function drawTick(dc as Dc, cx as Number, cy as Number, rIn as Number, rOut as Number,
|
// 两条顶弧:从 9 点 / 3 点那一端朝 12 点方向填充(fromEnd=true)
|
||||||
w as Number, ang as Number, col as Number, mirror as Boolean) as Void {
|
// 两条底弧:从 6 点朝两侧填充(fromEnd=false)
|
||||||
var eff = mirror ? -ang : ang;
|
// mirror 用来把同一组角度镜像到左半边,这样左右弧共用一份角度表和配色表。
|
||||||
var rad = Math.toRadians(eff);
|
function arc(dc as Dc, ramp as Array<Number>, off as Number, lit as Number,
|
||||||
var sin = Math.sin(rad);
|
phase as Float, pitch as Float, hw as Float, n as Number,
|
||||||
var cos = Math.cos(rad);
|
base as Float, mirror as Boolean, fromEnd as Boolean) as Void {
|
||||||
var ix = (cx + sin * rIn).toNumber();
|
|
||||||
var iy = (cy - cos * rIn).toNumber();
|
|
||||||
var ox = (cx + sin * rOut).toNumber();
|
|
||||||
var oy = (cy - cos * rOut).toNumber();
|
|
||||||
dc.setPenWidth(w);
|
|
||||||
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
|
|
||||||
dc.drawLine(ix, iy, ox, oy);
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawArcTop(dc as Dc, cx as Number, cy as Number, rIn as Number, rOut as Number,
|
|
||||||
w as Number, angles as Array<Number>, stops as Array<Array<Number>>,
|
|
||||||
off as Number, mirror as Boolean, lit as Number, anchorOuter as Boolean) as Void {
|
|
||||||
var n = angles.size();
|
|
||||||
for (var i = 0; i < n; i++) {
|
for (var i = 0; i < n; i++) {
|
||||||
var a = angles[i];
|
var a = phase + pitch * i;
|
||||||
var on = anchorOuter ? (i >= n - lit) : (i < lit);
|
var on = fromEnd ? (i >= n - lit) : (i < lit);
|
||||||
var col = on ? lerpStops(stops, a) : off;
|
dc.setColor(on ? ramp[i] : off, Graphics.COLOR_TRANSPARENT);
|
||||||
drawTick(dc, cx, cy, rIn, rOut, w, a, col, mirror);
|
tick(dc, base + (mirror ? -a : a), hw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawArcBottom(dc as Dc, cx as Number, cy as Number, rIn as Number, rOut as Number,
|
function drawRing(dc as Dc, ti as Number, pTR as Float, pTL as Float,
|
||||||
w as Number, botAngles as Array<Number>, stops as Array<Array<Number>>,
|
pBR as Float, pBL as Float) as Void {
|
||||||
off as Number, mirror as Boolean, lit as Number) as Void {
|
// 四段弧共用两份配色表:两条顶弧用 RINGTOP,两条底弧用 RINGBOTTOM。
|
||||||
var n = botAngles.size();
|
// 左右弧靠 mirror 参数镜像,颜色按刻度下标取,所以左右对称位置同色。
|
||||||
for (var i = 0; i < n; i++) {
|
var top = Themes.RINGTOP[ti] as Array<Number>;
|
||||||
var a = botAngles[i];
|
var bot = Themes.RINGBOTTOM[ti] as Array<Number>;
|
||||||
var on = (i < lit);
|
var off = Themes.TICKOFF[ti] as Number;
|
||||||
var ang = (180.0 - a).toNumber();
|
|
||||||
var col = on ? lerpStops(stops, a) : off;
|
arc(dc, top, off, (pTR * Layout.TOP_N).toNumber(), Layout.TOP_PH,
|
||||||
drawTick(dc, cx, cy, rIn, rOut, w, ang, col, mirror);
|
Layout.TOP_PITCH, Layout.TOP_HW, Layout.TOP_N, 0.0, false, true);
|
||||||
|
arc(dc, top, off, (pTL * Layout.TOP_N).toNumber(), Layout.TOP_PH,
|
||||||
|
Layout.TOP_PITCH, Layout.TOP_HW, Layout.TOP_N, 0.0, true, true);
|
||||||
|
arc(dc, bot, off, (pBL * Layout.BOT_N).toNumber(), Layout.BOT_PH,
|
||||||
|
Layout.BOT_PITCH, Layout.BOT_HW, Layout.BOT_N, 180.0, false, false);
|
||||||
|
arc(dc, bot, off, (pBR * Layout.BOT_N).toNumber(), Layout.BOT_PH,
|
||||||
|
Layout.BOT_PITCH, Layout.BOT_HW, Layout.BOT_N, 180.0, true, false);
|
||||||
|
|
||||||
|
// 四个锚点圆点,压在四段弧的接缝处。
|
||||||
|
dc.setColor(Themes.ANCHORDOT[ti] as Number, Graphics.COLOR_TRANSPARENT);
|
||||||
|
var r = Layout.px(Layout.DOT_R);
|
||||||
|
var rad = Layout.DOT_RAD * Layout.scale;
|
||||||
|
for (var i = 0; i < Layout.DOT_ANG.size(); i++) {
|
||||||
|
var a = Layout.DOT_ANG[i];
|
||||||
|
dc.fillCircle(Layout.polarX(a, rad), Layout.polarY(a, rad), r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawRing(dc as Dc, cx as Number, cy as Number, s as Float, theme as Dictionary,
|
// ------------------------------- 各元素 -------------------------------
|
||||||
pTR as Float, pTL as Float, pBR as Float, pBL as Float) as Void {
|
// 顶部中央:电池图标 + 百分比。
|
||||||
var rOut = (249.5 * s).toNumber();
|
function drawBattery(dc as Dc, ti as Number, level as Float) as Void {
|
||||||
var rIn = (231.5 * s).toNumber();
|
Icons.battery(dc, Layout.BATT_X, Layout.BATT_CY, 1.0,
|
||||||
var tickOff = theme["tickOff"] as Number;
|
Themes.ACCENT[ti] as Number, level);
|
||||||
var topStops = theme["ringTop"] as Array<Array<Number>>;
|
Layout.text(dc, Layout.BATT_VX, Layout.BATT_CY, Layout.fontBand,
|
||||||
var botStops = theme["ringBottom"] as Array<Array<Number>>;
|
(level * 100.0).toNumber().format("%d") + "%",
|
||||||
var topW = (13.0 * s).toNumber();
|
Graphics.TEXT_JUSTIFY_LEFT, Themes.TEXTPRIMARY[ti] as Number);
|
||||||
var botW = (9.0 * s).toNumber();
|
}
|
||||||
|
|
||||||
var TOP = [2,6,10,14,18,22,26,30,34,38,42,46,50,54,58,62,66,70,74,78,82,86];
|
// 顶部单个数据位:上方图标 + 下方数值。
|
||||||
var BOT = [6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57,60,63,66];
|
// 数值走 fittedText,太长会自动降字号,不会冲出表盘。
|
||||||
|
function drawTopSlot(dc as Dc, ti as Number, id as Number, isLeft as Boolean,
|
||||||
|
ctx as Fields.Ctx, level as Float) as Void {
|
||||||
|
var x = isLeft ? Layout.LEFT_X : Layout.RIGHT_X;
|
||||||
|
Icons.forField(dc, id, x, Themes.ACCENT[ti] as Number, level);
|
||||||
|
Layout.fittedText(dc, x, Layout.VALUE_CY, Layout.fontComp,
|
||||||
|
Fields.value(id, ctx), Graphics.TEXT_JUSTIFY_CENTER,
|
||||||
|
Themes.TEXTPRIMARY[ti] as Number, Layout.TOP_MAX_W);
|
||||||
|
}
|
||||||
|
|
||||||
var litTR = (pTR * 22.0).toNumber();
|
// 日期带:整条横幅底色 + 蓝牙符号 + 星期 + 月日 + 上午/下午。
|
||||||
var litTL = (pTL * 22.0).toNumber();
|
// 24 小时制下不显示上午/下午(跟随手表设置)。
|
||||||
var litBR = (pBR * 21.0).toNumber();
|
function drawDateBand(dc as Dc, ti as Number, ds as System.DeviceSettings?) as Void {
|
||||||
var litBL = (pBL * 21.0).toNumber();
|
var fill = Themes.BANDFILL[ti] as Number;
|
||||||
|
dc.setColor(fill, fill);
|
||||||
drawArcTop(dc, cx, cy, rIn, rOut, topW, TOP, topStops, tickOff, false, litTR, true);
|
dc.fillRectangle(0, Layout.px(Layout.BAND_Y), Layout.width,
|
||||||
drawArcTop(dc, cx, cy, rIn, rOut, topW, TOP, topStops, tickOff, true, litTL, true);
|
Layout.px(Layout.BAND_H));
|
||||||
drawArcBottom(dc, cx, cy, rIn, rOut, botW, BOT, botStops, tickOff, true, litBL);
|
var col = Themes.BANDTEXT[ti] as Number;
|
||||||
drawArcBottom(dc, cx, cy, rIn, rOut, botW, BOT, botStops, tickOff, false, litBR);
|
// 手机断连时把蓝牙符号**压暗**而不是隐藏 —— 否则手机一走远日期带就
|
||||||
|
// 少一块,整条带子的视觉重心跟着变,很干扰。
|
||||||
// anchor dots
|
Icons.bluetooth(dc, (ds != null && !ds.phoneConnected)
|
||||||
var dotR = (9.5 * s).toNumber();
|
? blend(col, fill, 0.6) : col);
|
||||||
var dotRad = (240.5 * s).toNumber();
|
// ⚠️ 必须用 FORMAT_SHORT,不能用 FORMAT_MEDIUM。
|
||||||
var dotCol = theme["anchorDot"] as Number;
|
// 只有短格式的 day_of_week / month 返回的是**数字**;中格式返回的是
|
||||||
var dotAngles = [0.0, 93.3, 180.0, 266.7];
|
// 本地化字符串,拿去做数组下标会直接抛类型异常 —— 初版就是这么崩的,
|
||||||
dc.setColor(dotCol, Graphics.COLOR_TRANSPARENT);
|
// 而且是每次更新都崩。
|
||||||
for (var i = 0; i < dotAngles.size(); i++) {
|
var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
|
||||||
var rad = Math.toRadians(dotAngles[i]);
|
Layout.text(dc, Layout.DOW_CX, Layout.BAND_CY, Layout.fontBand,
|
||||||
var dx = (cx + Math.sin(rad) * dotRad).toNumber();
|
Fields.dow(g), Graphics.TEXT_JUSTIFY_CENTER, col);
|
||||||
var dy = (cy - Math.cos(rad) * dotRad).toNumber();
|
Layout.text(dc, Layout.MD_CX, Layout.BAND_CY, Layout.fontBand,
|
||||||
dc.fillCircle(dx, dy, dotR);
|
Fields.monthDay(g), Graphics.TEXT_JUSTIFY_CENTER, col);
|
||||||
|
if (ds == null || !ds.is24Hour) {
|
||||||
|
Layout.text(dc, Layout.AMPM_R, Layout.BAND_CY, Layout.fontBand,
|
||||||
|
Fields.meridiem(g), Graphics.TEXT_JUSTIFY_RIGHT, col);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- complications
|
// 中央时间。小时和分钟各自带竖向渐变(gradText 内部会分带多次绘制),
|
||||||
function drawBattery(dc as Dc, cx as Number, cy as Number, s as Float, theme as Dictionary,
|
// 中间的冒号是两个实心方块,位置写死在设计稿坐标上。
|
||||||
stats as System.Stats) as Void {
|
function drawTime(dc as Dc, ti as Number, ds as System.DeviceSettings?) as Void {
|
||||||
var y = (53.5 * s).toNumber();
|
var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
|
||||||
var pct = 0;
|
var h = g.hour;
|
||||||
if (stats != null && stats.battery != null) { pct = (stats.battery * 100.0).toNumber(); }
|
if (ds == null || !ds.is24Hour) {
|
||||||
var txt = pct.format("%d") + "%";
|
if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; }
|
||||||
var gw = (30.0 * s).toNumber();
|
}
|
||||||
var gh = (16.0 * s).toNumber();
|
Layout.gradText(dc, Layout.HOURS_R, Layout.TIME_CY, Layout.fontTime,
|
||||||
var gx = (cx - 52.0 * s).toNumber();
|
h.format("%d"), Graphics.TEXT_JUSTIFY_RIGHT,
|
||||||
var gy = (y - gh / 2).toNumber();
|
Themes.HOURS[ti] as Array<Number>);
|
||||||
dc.setPenWidth(2);
|
Layout.gradText(dc, Layout.MINUTES_L, Layout.TIME_CY, Layout.fontTime,
|
||||||
dc.setColor(theme["accent"] as Number, Graphics.COLOR_TRANSPARENT);
|
g.min.format("%02d"), Graphics.TEXT_JUSTIFY_LEFT,
|
||||||
dc.drawRectangle(gx, gy, gw, gh);
|
Themes.MINUTES[ti] as Array<Number>);
|
||||||
dc.fillRectangle((gx + gw).toNumber(), (gy + gh * 0.28).toNumber(),
|
dc.setColor(Themes.COLON[ti] as Number, Graphics.COLOR_TRANSPARENT);
|
||||||
(3.0 * s).toNumber(), (gh * 0.44).toNumber());
|
dc.fillRectangle(Layout.px(Layout.COLON_X), Layout.px(Layout.COLON_Y1),
|
||||||
var fillW = ((gw - 4) * (pct.toFloat() / 100.0)).toNumber();
|
Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H));
|
||||||
dc.fillRectangle((gx + 2).toNumber(), (gy + 2).toNumber(), fillW, (gh - 4).toNumber());
|
dc.fillRectangle(Layout.px(Layout.COLON_X), Layout.px(Layout.COLON_Y2),
|
||||||
dc.setColor(theme["textPrimary"] as Number, Graphics.COLOR_TRANSPARENT);
|
Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H));
|
||||||
dc.drawText((cx + 16.0 * s).toNumber(), y, Graphics.FONT_SMALL, txt,
|
|
||||||
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawComplicationIcon(dc as Dc, x as Number, y as Number, r as Number, col as Number,
|
// 底部两格:各自「数值 + 小标签」。
|
||||||
typeIdx as Number) as Void {
|
// 标签跟随所选字段自动变化,不需要单独配置。
|
||||||
dc.setPenWidth(2);
|
function drawBottom(dc as Dc, ti as Number, ctx as Fields.Ctx) as Void {
|
||||||
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
|
var value = Themes.TEXTPRIMARY[ti] as Number;
|
||||||
if (typeIdx == 0) { // sun
|
var label = Themes.ACCENT[ti] as Number;
|
||||||
dc.fillCircle(x, y, (r * 0.45).toNumber());
|
var left = Settings.bottomLeft();
|
||||||
for (var i = 0; i < 8; i++) {
|
var right = Settings.bottomRight();
|
||||||
var rad = Math.toRadians(i * 45.0);
|
Layout.fittedText(dc, Layout.DIST_R, Layout.BOT_VAL_CY, Layout.fontComp,
|
||||||
var x1 = (x + Math.sin(rad) * r * 0.6).toNumber();
|
Fields.value(left, ctx), Graphics.TEXT_JUSTIFY_RIGHT,
|
||||||
var y1 = (y - Math.cos(rad) * r * 0.6).toNumber();
|
value, Layout.BOT_MAX_W);
|
||||||
var x2 = (x + Math.sin(rad) * r).toNumber();
|
Layout.fittedText(dc, Layout.DIST_R, Layout.BOT_LAB_CY, Layout.fontComp,
|
||||||
var y2 = (y - Math.cos(rad) * r).toNumber();
|
Fields.label(left), Graphics.TEXT_JUSTIFY_RIGHT,
|
||||||
dc.drawLine(x1, y1, x2, y2);
|
label, Layout.BOT_MAX_W);
|
||||||
|
Layout.fittedText(dc, Layout.STEP_L, Layout.BOT_VAL_CY, Layout.fontComp,
|
||||||
|
Fields.value(right, ctx), Graphics.TEXT_JUSTIFY_LEFT,
|
||||||
|
value, Layout.BOT_MAX_W);
|
||||||
|
Layout.fittedText(dc, Layout.STEP_L, Layout.BOT_LAB_CY, Layout.fontComp,
|
||||||
|
Fields.label(right), Graphics.TEXT_JUSTIFY_LEFT,
|
||||||
|
label, Layout.BOT_MAX_W);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------- 颜色工具 ------------------------------
|
||||||
|
// 两色按比例混合,f=0 取 c1,f=1 取 c2。
|
||||||
|
function blend(c1 as Number, c2 as Number, f as Float) as Number {
|
||||||
|
var r1 = (c1 >> 16) & 0xFF;
|
||||||
|
var g1 = (c1 >> 8) & 0xFF;
|
||||||
|
var b1 = c1 & 0xFF;
|
||||||
|
var r = r1 + (((((c2 >> 16) & 0xFF) - r1) * f)).toNumber();
|
||||||
|
var g = g1 + (((((c2 >> 8) & 0xFF) - g1) * f)).toNumber();
|
||||||
|
var b = b1 + ((((c2 & 0xFF) - b1) * f)).toNumber();
|
||||||
|
return (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整体压暗到 f 倍(常亮模式用)。
|
||||||
|
function dim(col as Number, f as Float) as Number {
|
||||||
|
var r = (((col >> 16) & 0xFF) * f).toNumber();
|
||||||
|
var g = (((col >> 8) & 0xFF) * f).toNumber();
|
||||||
|
var b = ((col & 0xFF) * f).toNumber();
|
||||||
|
return (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------- 常亮模式 ------------------------------
|
||||||
|
// 低功耗画面。相比正常画面砍掉的东西:
|
||||||
|
// - 日期带底色(整条亮橙色横幅是最费电的元素,直接不画)
|
||||||
|
// - 未点亮的刻度(只画有数据的那部分,且压暗到 45%)
|
||||||
|
// - 四个数据位、电池(全部隐藏)
|
||||||
|
// 保留的时间和日期压暗到 55%。
|
||||||
|
//
|
||||||
|
// 另外整个画面按分钟在 ±4 像素内游走,避免同一个像素长期点亮导致烧屏。
|
||||||
|
function drawAmbient(dc as Dc, ti as Number, ds as System.DeviceSettings?,
|
||||||
|
pTR as Float, pTL as Float, pBR as Float, pBL as Float) as Void {
|
||||||
|
var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
|
||||||
|
var ox = ((g.min % 5) - 2) * 2.0 / Layout.scale; // ±4 设备像素,换算回设计像素
|
||||||
|
var oy = ((g.min / 5) % 5 - 2) * 2.0 / Layout.scale;
|
||||||
|
|
||||||
|
var top = Themes.RINGTOP[ti] as Array<Number>;
|
||||||
|
var bot = Themes.RINGBOTTOM[ti] as Array<Number>;
|
||||||
|
var litTR = (pTR * Layout.TOP_N).toNumber();
|
||||||
|
var litTL = (pTL * Layout.TOP_N).toNumber();
|
||||||
|
var litBR = (pBR * Layout.BOT_N).toNumber();
|
||||||
|
var litBL = (pBL * Layout.BOT_N).toNumber();
|
||||||
|
for (var i = 0; i < Layout.TOP_N; i++) {
|
||||||
|
var a = Layout.TOP_PH + Layout.TOP_PITCH * i;
|
||||||
|
if (i >= Layout.TOP_N - litTR) {
|
||||||
|
dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT);
|
||||||
|
tick(dc, a, Layout.TOP_HW);
|
||||||
|
}
|
||||||
|
if (i >= Layout.TOP_N - litTL) {
|
||||||
|
dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT);
|
||||||
|
tick(dc, -a, Layout.TOP_HW);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var i = 0; i < Layout.BOT_N; i++) {
|
||||||
|
var a = Layout.BOT_PH + Layout.BOT_PITCH * i;
|
||||||
|
if (i < litBL) {
|
||||||
|
dc.setColor(dim(bot[i], 0.45), Graphics.COLOR_TRANSPARENT);
|
||||||
|
tick(dc, 180.0 + a, Layout.BOT_HW);
|
||||||
|
}
|
||||||
|
if (i < litBR) {
|
||||||
|
dc.setColor(dim(bot[i], 0.45), Graphics.COLOR_TRANSPARENT);
|
||||||
|
tick(dc, 180.0 - a, Layout.BOT_HW);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var h = g.hour;
|
||||||
|
if (ds == null || !ds.is24Hour) {
|
||||||
|
if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; }
|
||||||
|
}
|
||||||
|
var hours = Themes.HOURS[ti] as Array<Number>;
|
||||||
|
var mins = Themes.MINUTES[ti] as Array<Number>;
|
||||||
|
Layout.text(dc, Layout.HOURS_R + ox, Layout.TIME_CY + oy, Layout.fontTime,
|
||||||
|
h.format("%d"), Graphics.TEXT_JUSTIFY_RIGHT,
|
||||||
|
dim(hours[hours.size() / 2], 0.55));
|
||||||
|
Layout.text(dc, Layout.MINUTES_L + ox, Layout.TIME_CY + oy, Layout.fontTime,
|
||||||
|
g.min.format("%02d"), Graphics.TEXT_JUSTIFY_LEFT,
|
||||||
|
dim(mins[mins.size() / 2], 0.55));
|
||||||
|
dc.setColor(dim(Themes.COLON[ti] as Number, 0.55), Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.fillRectangle(Layout.px(Layout.COLON_X + ox), Layout.px(Layout.COLON_Y1 + oy),
|
||||||
|
Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H));
|
||||||
|
dc.fillRectangle(Layout.px(Layout.COLON_X + ox), Layout.px(Layout.COLON_Y2 + oy),
|
||||||
|
Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H));
|
||||||
|
|
||||||
|
var band = dim(Themes.ACCENT[ti] as Number, 0.55);
|
||||||
|
Layout.text(dc, Layout.DOW_CX + ox, Layout.BAND_CY + oy, Layout.fontBand,
|
||||||
|
Fields.dow(g), Graphics.TEXT_JUSTIFY_CENTER, band);
|
||||||
|
Layout.text(dc, Layout.MD_CX + ox, Layout.BAND_CY + oy, Layout.fontBand,
|
||||||
|
Fields.monthDay(g), Graphics.TEXT_JUSTIFY_CENTER, band);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------- 秒针刷新 ------------------------------
|
||||||
|
// 表盘正常只有每分钟一次的 onUpdate,秒数根本走不动。
|
||||||
|
// 这里实现 onPartialUpdate(系统每秒回调一次),但要非常克制:
|
||||||
|
// - 只有当真的有数据位选了「秒」时才做事,否则直接返回
|
||||||
|
// - 只重绘那一格,用 setClip 把绘制限制在格子的包围盒内
|
||||||
|
// (每秒重绘整个表盘的功耗预算是不够的)
|
||||||
|
// - 常亮的 AMOLED 机型直接跳过:那时数据位本来就不显示
|
||||||
|
function isSeconds(id as Number) as Boolean {
|
||||||
|
return id == 850 || id == 869;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wantsSeconds() as Boolean {
|
||||||
|
if (Settings.showTop()
|
||||||
|
&& (isSeconds(Settings.leftTop()) || isSeconds(Settings.rightTop()))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Settings.showBottom()
|
||||||
|
&& (isSeconds(Settings.bottomLeft()) || isSeconds(Settings.bottomRight()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重绘单个格子:先把它的包围盒涂黑,再把新值画回去。
|
||||||
|
function repaintSlot(dc as Dc, ti as Number, ctx as Fields.Ctx, id as Number,
|
||||||
|
x as Numeric, cyDesign as Numeric, just as Number,
|
||||||
|
maxW as Float, col as Number) as Void {
|
||||||
|
var half = maxW / 2.0;
|
||||||
|
var x0 = x - half;
|
||||||
|
if (just == Graphics.TEXT_JUSTIFY_RIGHT) { x0 = x - maxW; }
|
||||||
|
else if (just == Graphics.TEXT_JUSTIFY_LEFT) { x0 = x; }
|
||||||
|
var h = Layout.CAP_COMP * 1.6;
|
||||||
|
dc.setClip(Layout.px(x0), Layout.px(cyDesign - h / 2),
|
||||||
|
Layout.px(maxW), Layout.px(h));
|
||||||
|
// 全屏涂黑。设计稿的底色就是纯黑,AMOLED 上黑像素也最省电。
|
||||||
|
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK);
|
||||||
|
dc.clear();
|
||||||
|
Layout.fittedText(dc, x, cyDesign, Layout.fontComp, Fields.value(id, ctx),
|
||||||
|
just, col, maxW);
|
||||||
|
dc.clearClip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPartialUpdate(dc as Dc) as Void {
|
||||||
|
if (mBurnIn || !Layout.ready() || !Fields.ready()) { return; }
|
||||||
|
if (!wantsSeconds()) { return; }
|
||||||
|
var ti = Settings.themeIndex(Themes.NAMES.size());
|
||||||
|
var ctx = new Fields.Ctx(System.getDeviceSettings(), Settings.tempUnit());
|
||||||
|
var col = Themes.TEXTPRIMARY[ti] as Number;
|
||||||
|
if (Settings.showTop()) {
|
||||||
|
if (isSeconds(Settings.leftTop())) {
|
||||||
|
repaintSlot(dc, ti, ctx, Settings.leftTop(), Layout.LEFT_X,
|
||||||
|
Layout.VALUE_CY, Graphics.TEXT_JUSTIFY_CENTER,
|
||||||
|
Layout.TOP_MAX_W, col);
|
||||||
|
}
|
||||||
|
if (isSeconds(Settings.rightTop())) {
|
||||||
|
repaintSlot(dc, ti, ctx, Settings.rightTop(), Layout.RIGHT_X,
|
||||||
|
Layout.VALUE_CY, Graphics.TEXT_JUSTIFY_CENTER,
|
||||||
|
Layout.TOP_MAX_W, col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Settings.showBottom()) {
|
||||||
|
if (isSeconds(Settings.bottomLeft())) {
|
||||||
|
repaintSlot(dc, ti, ctx, Settings.bottomLeft(), Layout.DIST_R,
|
||||||
|
Layout.BOT_VAL_CY, Graphics.TEXT_JUSTIFY_RIGHT,
|
||||||
|
Layout.BOT_MAX_W, col);
|
||||||
|
}
|
||||||
|
if (isSeconds(Settings.bottomRight())) {
|
||||||
|
repaintSlot(dc, ti, ctx, Settings.bottomRight(), Layout.STEP_L,
|
||||||
|
Layout.BOT_VAL_CY, Graphics.TEXT_JUSTIFY_LEFT,
|
||||||
|
Layout.BOT_MAX_W, col);
|
||||||
}
|
}
|
||||||
} else if (typeIdx == 1) { // flame
|
|
||||||
dc.fillPolygon([[x, (y - r).toNumber()],
|
|
||||||
[(x + r * 0.8).toNumber(), (y + r * 0.6).toNumber()],
|
|
||||||
[(x - r * 0.8).toNumber(), (y + r * 0.6).toNumber()]]);
|
|
||||||
} else if (typeIdx == 2) { // steps
|
|
||||||
dc.drawLine((x - r * 0.5).toNumber(), (y - r * 0.3).toNumber(),
|
|
||||||
(x + r * 0.5).toNumber(), (y - r * 0.3).toNumber());
|
|
||||||
dc.drawLine((x - r * 0.3).toNumber(), y, (x + r * 0.6).toNumber(), y);
|
|
||||||
} else if (typeIdx == 3) { // distance dot
|
|
||||||
dc.fillCircle(x, y, (r * 0.5).toNumber());
|
|
||||||
} else if (typeIdx == 4) { // floors chevron
|
|
||||||
dc.fillPolygon([[x, (y - r).toNumber()],
|
|
||||||
[(x + r * 0.7).toNumber(), (y + r * 0.2).toNumber()],
|
|
||||||
[(x - r * 0.7).toNumber(), (y + r * 0.2).toNumber()]]);
|
|
||||||
} else { // battery
|
|
||||||
dc.drawRectangle((x - r * 0.6).toNumber(), (y - r * 0.4).toNumber(),
|
|
||||||
(r * 1.2).toNumber(), (r * 0.8).toNumber());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawTopComplication(dc as Dc, cx as Number, cy as Number, s as Float, theme as Dictionary,
|
// ------------------------------- 主绘制 --------------------------------
|
||||||
typeIdx as Number, isLeft as Boolean, info as ActivityMonitor.Info,
|
// 每分钟调用一次(以及唤醒、设置变更时)。
|
||||||
weather as Weather.CurrentConditions, stats as System.Stats) as Void {
|
|
||||||
var sideX = isLeft ? (156.0 * s).toNumber() : (343.0 * s).toNumber();
|
|
||||||
var iconY = (90.0 * s).toNumber();
|
|
||||||
var valY = (130.5 * s).toNumber();
|
|
||||||
var valStr = complicationValue(typeIdx, info, weather, stats);
|
|
||||||
dc.setColor(theme["textPrimary"] as Number, Graphics.COLOR_TRANSPARENT);
|
|
||||||
dc.drawText(sideX, valY, Graphics.FONT_MEDIUM, valStr,
|
|
||||||
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
||||||
drawComplicationIcon(dc, sideX, iconY, (13.0 * s).toNumber(), theme["accent"] as Number, typeIdx);
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawBluetooth(dc as Dc, cx as Number, cy as Number, r as Number, col as Number) as Void {
|
|
||||||
dc.setPenWidth(3);
|
|
||||||
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
|
|
||||||
dc.drawLine(cx, (cy - r).toNumber(), cx, (cy + r).toNumber());
|
|
||||||
dc.drawLine(cx, (cy - r).toNumber(), (cx + r * 0.7).toNumber(), (cy - r * 0.3).toNumber());
|
|
||||||
dc.drawLine((cx + r * 0.7).toNumber(), (cy - r * 0.3).toNumber(), cx, cy);
|
|
||||||
dc.drawLine(cx, cy, (cx + r * 0.7).toNumber(), (cy + r * 0.3).toNumber());
|
|
||||||
dc.drawLine((cx + r * 0.7).toNumber(), (cy + r * 0.3).toNumber(), cx, (cy + r).toNumber());
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawDateBand(dc as Dc, w as Number, cx as Number, s as Float, theme as Dictionary,
|
|
||||||
ds as System.DeviceSettings) as Void {
|
|
||||||
var by = (286.5 * s).toNumber();
|
|
||||||
var bh = (44.0 * s).toNumber();
|
|
||||||
dc.setColor(theme["bandFill"] as Number, theme["bandFill"] as Number);
|
|
||||||
dc.fillRectangle(0, by, w, bh);
|
|
||||||
var txtCol = theme["bandText"] as Number;
|
|
||||||
var yc = (308.5 * s).toNumber();
|
|
||||||
drawBluetooth(dc, (36.5 * s).toNumber(), yc, (10.0 * s).toNumber(), txtCol);
|
|
||||||
var gi = Gregorian.info(Time.now(), Time.FORMAT_MEDIUM);
|
|
||||||
var DOW = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
|
|
||||||
var MON = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
|
|
||||||
var dow = DOW[gi.day_of_week - 1];
|
|
||||||
var md = MON[gi.month - 1] + " " + gi.day.format("%d");
|
|
||||||
dc.setColor(txtCol, Graphics.COLOR_TRANSPARENT);
|
|
||||||
dc.drawText((177.5 * s).toNumber(), yc, Graphics.FONT_SMALL, dow,
|
|
||||||
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
||||||
dc.drawText((297.5 * s).toNumber(), yc, Graphics.FONT_SMALL, md,
|
|
||||||
Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
||||||
var ampm = gi.hour >= 12 ? "PM" : "AM";
|
|
||||||
dc.drawText((465.0 * s).toNumber(), yc, Graphics.FONT_SMALL, ampm,
|
|
||||||
Graphics.TEXT_JUSTIFY_RIGHT | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawTime(dc as Dc, cx as Number, cy as Number, s as Float, theme as Dictionary) as Void {
|
|
||||||
var gi = Gregorian.info(Time.now(), Time.FORMAT_MEDIUM);
|
|
||||||
var hh = gi.hour;
|
|
||||||
if (hh == 0) { hh = 12; } else if (hh > 12) { hh = hh - 12; }
|
|
||||||
var hStr = hh.format("%d");
|
|
||||||
var mStr = gi.min.format("%02d");
|
|
||||||
var ty = (220.0 * s).toNumber();
|
|
||||||
dc.setColor(theme["hours"] as Number, Graphics.COLOR_TRANSPARENT);
|
|
||||||
dc.drawText((211.0 * s).toNumber(), ty, Graphics.FONT_NUMBER_HOT, hStr,
|
|
||||||
Graphics.TEXT_JUSTIFY_RIGHT | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
||||||
dc.setColor(theme["minutes"] as Number, Graphics.COLOR_TRANSPARENT);
|
|
||||||
dc.drawText((272.0 * s).toNumber(), ty, Graphics.FONT_NUMBER_HOT, mStr,
|
|
||||||
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
||||||
// colon: two blocks centred on x=250
|
|
||||||
dc.setColor(theme["colon"] as Number, Graphics.COLOR_TRANSPARENT);
|
|
||||||
var cw = (24.0 * s).toNumber();
|
|
||||||
var ch = (20.0 * s).toNumber();
|
|
||||||
var cxc = (238.0 * s).toNumber();
|
|
||||||
dc.fillRectangle(cxc, (184.0 * s).toNumber(), cw, ch);
|
|
||||||
dc.fillRectangle(cxc, (235.0 * s).toNumber(), cw, ch);
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawBottom(dc as Dc, cx as Number, s as Float, theme as Dictionary,
|
|
||||||
info as ActivityMonitor.Info) as Void {
|
|
||||||
var valY = (366.0 * s).toNumber();
|
|
||||||
var labY = (410.5 * s).toNumber();
|
|
||||||
var leftX = (234.0 * s).toNumber();
|
|
||||||
var rightX = (266.0 * s).toNumber();
|
|
||||||
var dist = info != null && info.distance != null
|
|
||||||
? (info.distance.toFloat() / 100000.0).format("%.1f") : "0.0";
|
|
||||||
var steps = info != null && info.steps != null ? info.steps.format("%d") : "0";
|
|
||||||
dc.setColor(theme["textPrimary"] as Number, Graphics.COLOR_TRANSPARENT);
|
|
||||||
dc.drawText(leftX, valY, Graphics.FONT_MEDIUM, dist,
|
|
||||||
Graphics.TEXT_JUSTIFY_RIGHT | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
||||||
dc.setColor(theme["accent"] as Number, Graphics.COLOR_TRANSPARENT);
|
|
||||||
dc.drawText(leftX, labY, Graphics.FONT_SMALL, "DST",
|
|
||||||
Graphics.TEXT_JUSTIFY_RIGHT | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
||||||
dc.setColor(theme["textPrimary"] as Number, Graphics.COLOR_TRANSPARENT);
|
|
||||||
dc.drawText(rightX, valY, Graphics.FONT_MEDIUM, steps,
|
|
||||||
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
||||||
dc.setColor(theme["accent"] as Number, Graphics.COLOR_TRANSPARENT);
|
|
||||||
dc.drawText(rightX, labY, Graphics.FONT_SMALL, "STEP",
|
|
||||||
Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------- main
|
|
||||||
function onUpdate(dc as Dc) as Void {
|
function onUpdate(dc as Dc) as Void {
|
||||||
var w = dc.getWidth();
|
if (!Layout.ready() || Layout.width != dc.getWidth()
|
||||||
var h = dc.getHeight();
|
|| !Fields.ready() || !Icons.ready()) {
|
||||||
var s = w / 500.0;
|
onLayout(dc);
|
||||||
var cx = w / 2;
|
}
|
||||||
var cy = h / 2;
|
if (dc has :setAntiAlias) { dc.setAntiAlias(true); }
|
||||||
|
|
||||||
var theme = currentTheme();
|
// 每次绘制只构造一个 Ctx,四个数据位共用 —— 天气、用户档案这些
|
||||||
var info = ActivityMonitor.getInfo();
|
// 开销大的查询因此每帧最多做一次,且只在真有格子要用时才做。
|
||||||
var stats = System.getSystemStats();
|
var ti = Settings.themeIndex(Themes.NAMES.size());
|
||||||
var ds = System.getDeviceSettings();
|
var ds = System.getDeviceSettings();
|
||||||
var weather = Weather.getCurrentConditions();
|
var ctx = new Fields.Ctx(ds, Settings.tempUnit());
|
||||||
|
var info = ctx.info();
|
||||||
|
var stats = ctx.stats();
|
||||||
|
var level = 0.0f;
|
||||||
|
if (stats != null && stats.battery != null) { level = stats.battery / 100.0; }
|
||||||
|
if (level > 1.0f) { level = 1.0f; }
|
||||||
|
if (level < 0.0f) { level = 0.0f; }
|
||||||
|
|
||||||
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK);
|
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK);
|
||||||
dc.clear();
|
dc.clear();
|
||||||
|
|
||||||
var pTR = metricProgress(getSetting("RingTR", 0), info);
|
var pTR = ringProgress(Settings.ringTR(), info);
|
||||||
var pTL = metricProgress(getSetting("RingTL", 1), info);
|
var pTL = ringProgress(Settings.ringTL(), info);
|
||||||
var pBR = metricProgress(getSetting("RingBR", 4), info);
|
var pBR = ringProgress(Settings.ringBR(), info);
|
||||||
var pBL = metricProgress(getSetting("RingBL", 3), info);
|
var pBL = ringProgress(Settings.ringBL(), info);
|
||||||
drawRing(dc, cx, cy, s, theme, pTR, pTL, pBR, pBL);
|
|
||||||
|
|
||||||
if (getBool("ShowTop", true)) {
|
// 常亮模式走另一条绘制路径,画完直接返回。
|
||||||
drawBattery(dc, cx, cy, s, theme, stats);
|
if (mSleeping && mBurnIn) {
|
||||||
drawTopComplication(dc, cx, cy, s, theme, getSetting("LeftTop", 1), true, info, weather, stats);
|
drawAmbient(dc, ti, ds, pTR, pTL, pBR, pBL);
|
||||||
drawTopComplication(dc, cx, cy, s, theme, getSetting("RightTop", 0), false, info, weather, stats);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
drawTime(dc, cx, cy, s, theme);
|
drawRing(dc, ti, pTR, pTL, pBR, pBL);
|
||||||
|
|
||||||
if (getBool("ShowBand", true)) {
|
if (Settings.showTop()) {
|
||||||
drawDateBand(dc, w, cx, s, theme, ds);
|
drawBattery(dc, ti, level);
|
||||||
|
drawTopSlot(dc, ti, Settings.leftTop(), true, ctx, level);
|
||||||
|
drawTopSlot(dc, ti, Settings.rightTop(), false, ctx, level);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getBool("ShowBottom", true)) {
|
drawTime(dc, ti, ds);
|
||||||
drawBottom(dc, cx, s, theme, info);
|
|
||||||
|
if (Settings.showBand()) {
|
||||||
|
drawDateBand(dc, ti, ds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Settings.showBottom()) {
|
||||||
|
drawBottom(dc, ti, ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
source/FieldTable.mc
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// ⚠️ 本文件由 tools/gen_fields.py 自动生成,请勿手改。
|
||||||
|
// 要加字段:在生成器的 FIELDS 表里加一行,跑一遍生成器,
|
||||||
|
// 再到 source/Fields.mc 对应分类的函数里补一个分支。
|
||||||
|
//
|
||||||
|
// 字段编号沿用 https://watchface.io/docs/datafields,只收录 Connect IQ
|
||||||
|
// 表盘在本机能算出来的那些。
|
||||||
|
import Toybox.Lang;
|
||||||
|
|
||||||
|
module FieldTable {
|
||||||
|
|
||||||
|
// 全部字段 id,顺序与 FieldLabels 资源串(逗号分隔的标签表)严格一致 ——
|
||||||
|
// Fields.label() 就是靠这个下标去取对应标签的。
|
||||||
|
const IDS = [
|
||||||
|
0,860,880,864,865,851,872,852,855,870,856,853,868,858,859,850,869,873,1,14,707,2,
|
||||||
|
603,150,152,154,151,3,4,5,6,9,10,219,220,211,212,213,214,203,204,200,618,276,12,635,
|
||||||
|
11,623,8,15,281,742,13,278,277,250,251,257,253,255,254,256,252,615,616,733,712,717,
|
||||||
|
700,731,706,732,710,709,708,711,714,715,718,719,720,721,306,639,300,301,302,305,304,
|
||||||
|
308,307,310,309,312,313,314,315,500,515,503,504,505,507,502,501,506,509,510,511,518,
|
||||||
|
512,513,532,536,537,530,534,522,535,523,516,517,703,704,705
|
||||||
|
];
|
||||||
|
|
||||||
|
// 由字段 id 反查它在 IDS 里的下标;找不到返回 0(对应「关闭」)。
|
||||||
|
function indexOf(id as Number) as Number {
|
||||||
|
for (var i = 0; i < IDS.size(); i++) {
|
||||||
|
if (IDS[i] == id) { return i; }
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
798
source/Fields.mc
Normal file
@@ -0,0 +1,798 @@
|
|||||||
|
import Toybox.Lang;
|
||||||
|
import Toybox.Math;
|
||||||
|
import Toybox.System;
|
||||||
|
import Toybox.Time;
|
||||||
|
import Toybox.Time.Gregorian;
|
||||||
|
import Toybox.Application;
|
||||||
|
import Toybox.ActivityMonitor;
|
||||||
|
import Toybox.Activity;
|
||||||
|
import Toybox.UserProfile;
|
||||||
|
import Toybox.SensorHistory;
|
||||||
|
import Toybox.Weather;
|
||||||
|
import Toybox.Position;
|
||||||
|
import Toybox.WatchUi;
|
||||||
|
import FieldTable;
|
||||||
|
import Settings;
|
||||||
|
import Solar;
|
||||||
|
import Owm;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 数据字段
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 四个数据位能显示的全部内容,共 125 项。
|
||||||
|
//
|
||||||
|
// 字段编号沿用 https://watchface.io/docs/datafields,两边可以直接对照。
|
||||||
|
// 驱动设置项和标签文案的那张表在 tools/gen_fields.py —— 加字段要改那里,
|
||||||
|
// 然后在本文件对应分类的函数里补一个分支。
|
||||||
|
//
|
||||||
|
// 只收录**手表本机能算出来**的字段。没做的几类及原因见 README 第 7 节,简单说:
|
||||||
|
// - 按运动分类的周/月累计:CIQ 只有按天的总计,没有分运动的历史
|
||||||
|
// - StormGlass 潮汐、第三方 App 复杂功能:需要额外 key 或用户装了对应 App
|
||||||
|
// - 日历、计时器:CIQ 不开放日历,表盘也拿不到触摸输入
|
||||||
|
//
|
||||||
|
// 组织方式:value() 按分类依次尝试 7 个函数,每个函数不认识编号就返回 null。
|
||||||
|
// 之所以不按编号区间派发,是因为编号是**交错**的 —— 1/2/3 是活动,8/11/12 是
|
||||||
|
// 身体,14 又回到活动。
|
||||||
|
// ============================================================================
|
||||||
|
module Fields {
|
||||||
|
|
||||||
|
const DASH = "--";
|
||||||
|
|
||||||
|
var mDow as Array<String>?;
|
||||||
|
var mMon as Array<String>?;
|
||||||
|
var mLabels as Array<String>?;
|
||||||
|
var mMeridiem as Array<String>?;
|
||||||
|
var mDateFmt as String = "$1$ $2$";
|
||||||
|
|
||||||
|
function loadStrings() as Void {
|
||||||
|
mDow = splitCsv(WatchUi.loadResource(Rez.Strings.DowNames) as String);
|
||||||
|
mMon = splitCsv(WatchUi.loadResource(Rez.Strings.MonNames) as String);
|
||||||
|
mLabels = splitCsv(WatchUi.loadResource(Rez.Strings.FieldLabels) as String);
|
||||||
|
mMeridiem = splitCsv(WatchUi.loadResource(Rez.Strings.Meridiem) as String);
|
||||||
|
mDateFmt = WatchUi.loadResource(Rez.Strings.DateFormat) as String;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready() as Boolean { return mDow != null; }
|
||||||
|
|
||||||
|
// Monkey C 没有 String.split,只能自己按逗号切。
|
||||||
|
function splitCsv(str as String) as Array<String> {
|
||||||
|
var out = [] as Array<String>;
|
||||||
|
var rest = str;
|
||||||
|
var cut = rest.find(",");
|
||||||
|
while (cut != null) {
|
||||||
|
out.add(rest.substring(0, cut) as String);
|
||||||
|
rest = rest.substring(cut + 1, rest.length()) as String;
|
||||||
|
cut = rest.find(",");
|
||||||
|
}
|
||||||
|
out.add(rest);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dow(g as Gregorian.Info) as String {
|
||||||
|
return mDow == null ? "" : mDow[g.day_of_week - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function month(g as Gregorian.Info) as String {
|
||||||
|
return mMon == null ? "" : mMon[g.month - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthDay(g as Gregorian.Info) as String {
|
||||||
|
return Lang.format(mDateFmt, [month(g), g.day.format("%d")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 英文出 "10",中文出 "10日"。
|
||||||
|
// 后缀带在本地化的日期格式串里,所以这里把月份传空串即可。
|
||||||
|
function dayOnly(g as Gregorian.Info) as String {
|
||||||
|
return Lang.format(mDateFmt, ["", g.day.format("%d")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function meridiem(g as Gregorian.Info) as String {
|
||||||
|
return mMeridiem == null ? "" : mMeridiem[g.hour >= 12 ? 1 : 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部格子数值下方的小标签。按字段编号查 FieldTable 的下标,
|
||||||
|
// 再到本地化的标签表里取词,所以中英文会自动跟随系统语言。
|
||||||
|
function label(id as Number) as String {
|
||||||
|
if (mLabels == null) { return ""; }
|
||||||
|
var i = FieldTable.indexOf(id);
|
||||||
|
return i < mLabels.size() ? mLabels[i] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每次绘制构造一个,传给所有数据位共用。
|
||||||
|
// 目的是让开销大的查询(天气预报、传感器历史、用户档案)**每次绘制最多做
|
||||||
|
// 一次,且只在真的有格子要用时才做** —— 四个格子可能都要天气,但只查一次;
|
||||||
|
// 没有格子用到 UserProfile 时就完全不去碰它。
|
||||||
|
class Ctx {
|
||||||
|
var greg as Gregorian.Info;
|
||||||
|
var is24 as Boolean = true;
|
||||||
|
var metric as Boolean = true;
|
||||||
|
var fahrenheit as Boolean = false;
|
||||||
|
|
||||||
|
private var mInfo as ActivityMonitor.Info?;
|
||||||
|
private var mStats as System.Stats?;
|
||||||
|
private var mDs as System.DeviceSettings?;
|
||||||
|
private var mAct as Activity.Info?;
|
||||||
|
private var mWx as Weather.CurrentConditions?;
|
||||||
|
private var mFc as Array<Weather.DailyForecast>?;
|
||||||
|
private var mProf as UserProfile.Profile?;
|
||||||
|
private var mGotAct = false;
|
||||||
|
private var mGotWx = false;
|
||||||
|
private var mGotFc = false;
|
||||||
|
private var mGotProf = false;
|
||||||
|
|
||||||
|
function initialize(ds as System.DeviceSettings?, tempUnit as Number) {
|
||||||
|
mDs = ds;
|
||||||
|
greg = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
|
||||||
|
if (ds != null) {
|
||||||
|
is24 = ds.is24Hour;
|
||||||
|
metric = (ds.distanceUnits != System.UNIT_STATUTE);
|
||||||
|
fahrenheit = (tempUnit == 2)
|
||||||
|
|| (tempUnit == 0 && ds.temperatureUnits == System.UNIT_STATUTE);
|
||||||
|
}
|
||||||
|
if (tempUnit == 1) { fahrenheit = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ds() as System.DeviceSettings? { return mDs; }
|
||||||
|
|
||||||
|
function info() as ActivityMonitor.Info? {
|
||||||
|
if (mInfo == null) { mInfo = ActivityMonitor.getInfo(); }
|
||||||
|
return mInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stats() as System.Stats? {
|
||||||
|
if (mStats == null) { mStats = System.getSystemStats(); }
|
||||||
|
return mStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
function act() as Activity.Info? {
|
||||||
|
if (!mGotAct) { mAct = Activity.getActivityInfo(); mGotAct = true; }
|
||||||
|
return mAct;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wx() as Weather.CurrentConditions? {
|
||||||
|
if (!mGotWx) { mWx = Weather.getCurrentConditions(); mGotWx = true; }
|
||||||
|
return mWx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forecast() as Weather.DailyForecast? {
|
||||||
|
if (!mGotFc) { mFc = Weather.getDailyForecast(); mGotFc = true; }
|
||||||
|
if (mFc != null && mFc.size() > 0) { return mFc[0]; }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profile() as UserProfile.Profile? {
|
||||||
|
if (!mGotProf) { mProf = UserProfile.getProfile(); mGotProf = true; }
|
||||||
|
return mProf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日出/日落/晨昏蒙影都要位置,而两个实时来源都会间歇性为空:
|
||||||
|
// - 天气在同步之前没有观测点坐标
|
||||||
|
// - currentLocation 要有东西刚定过位才有值
|
||||||
|
// 所以每次拿到位置就缓存进 Storage,两个来源都空时用缓存兜底,
|
||||||
|
// 免得这些字段一会儿有一会儿变 "--"。
|
||||||
|
function where() as Position.Location? {
|
||||||
|
var w = wx();
|
||||||
|
if (w != null && (w has :observationLocationPosition)
|
||||||
|
&& w.observationLocationPosition != null) {
|
||||||
|
return remember(w.observationLocationPosition);
|
||||||
|
}
|
||||||
|
var a = act();
|
||||||
|
if (a != null && a.currentLocation != null) {
|
||||||
|
return remember(a.currentLocation);
|
||||||
|
}
|
||||||
|
var lat = Application.Storage.getValue("lastLat");
|
||||||
|
var lon = Application.Storage.getValue("lastLon");
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
return new Position.Location({:latitude => lat as Double,
|
||||||
|
:longitude => lon as Double,
|
||||||
|
:format => :degrees});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remember(loc as Position.Location) as Position.Location {
|
||||||
|
var d = loc.toDegrees();
|
||||||
|
Application.Storage.setValue("lastLat", d[0]);
|
||||||
|
Application.Storage.setValue("lastLon", d[1]);
|
||||||
|
return loc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------- 格式化工具 ----------------------------
|
||||||
|
function num(v as Numeric?) as String {
|
||||||
|
return v == null ? DASH : v.format("%d");
|
||||||
|
}
|
||||||
|
|
||||||
|
function one(v as Float?) as String {
|
||||||
|
return v == null ? DASH : v.format("%.1f");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clock(m as Time.Moment?, c as Ctx) as String {
|
||||||
|
if (m == null) { return DASH; }
|
||||||
|
return clockInfo(Gregorian.info(m, Time.FORMAT_SHORT), c);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clockInfo(g as Gregorian.Info, c as Ctx) as String {
|
||||||
|
var h = g.hour;
|
||||||
|
if (!c.is24) {
|
||||||
|
if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; }
|
||||||
|
}
|
||||||
|
return h.format("%d") + ":" + g.min.format("%02d");
|
||||||
|
}
|
||||||
|
|
||||||
|
function offsetClock(minutes as Number, c as Ctx) as String {
|
||||||
|
var m = Time.now().add(new Time.Duration(minutes * 60));
|
||||||
|
return clock(m, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
function temp(celsius as Numeric?, c as Ctx) as String {
|
||||||
|
if (celsius == null) { return DASH; }
|
||||||
|
var t = celsius.toFloat();
|
||||||
|
if (c.fahrenheit) { t = t * 9.0 / 5.0 + 32.0; }
|
||||||
|
return t.format("%d") + "°";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivityMonitor 的距离单位是**厘米**,先换算成公里再按单位制转换。
|
||||||
|
function dist(cm as Numeric?, c as Ctx) as String {
|
||||||
|
if (cm == null) { return DASH; }
|
||||||
|
var km = cm.toFloat() / 100000.0;
|
||||||
|
return (c.metric ? km : km * 0.621371).format("%.1f");
|
||||||
|
}
|
||||||
|
|
||||||
|
function metres(m as Numeric?, c as Ctx) as String {
|
||||||
|
if (m == null) { return DASH; }
|
||||||
|
var v = m.toFloat();
|
||||||
|
return (c.metric ? v : v * 3.28084).format("%d");
|
||||||
|
}
|
||||||
|
|
||||||
|
function rounded(v as Numeric?) as String {
|
||||||
|
if (v == null) { return DASH; }
|
||||||
|
var n = v.toNumber();
|
||||||
|
if (n < 1000) { return n.format("%d"); }
|
||||||
|
return (n / 1000.0).format("%.1f") + "k";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 SensorHistory 迭代器里取最新一条读数;没有则 null。
|
||||||
|
function latest(iter) as Numeric? {
|
||||||
|
if (iter == null) { return null; }
|
||||||
|
var s = iter.next();
|
||||||
|
if (s == null) { return null; }
|
||||||
|
return s.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 历史累计。
|
||||||
|
// ActivityMonitor.getHistory() 返回过去若干天(最新在前),**不含今天**。
|
||||||
|
// 把它加起来是本机唯一能做出「本周累计 / 近 7 天累计」的路子;
|
||||||
|
// 按运动项目细分的那些仍然做不到,那需要手机侧与 Garmin Connect 的集成。
|
||||||
|
//
|
||||||
|
// which:0 步数 / 1 卡路里 / 2 距离(厘米) / 3 楼层 / 4 活动分钟
|
||||||
|
// days :往前折算几天,今天总是算在内
|
||||||
|
function sumHistory(c as Ctx, which as Number, days as Number) as Numeric? {
|
||||||
|
var today = todayValue(c, which);
|
||||||
|
if (today == null) { return null; }
|
||||||
|
var total = today;
|
||||||
|
if (days <= 0) { return total; }
|
||||||
|
var hist = ActivityMonitor.getHistory();
|
||||||
|
if (hist == null) { return total; }
|
||||||
|
var n = days < hist.size() ? days : hist.size();
|
||||||
|
for (var k = 0; k < n; k++) {
|
||||||
|
var v = sampleValue(hist[k], which);
|
||||||
|
if (v != null) { total = total + v; }
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayValue(c as Ctx, which as Number) as Numeric? {
|
||||||
|
var i = c.info();
|
||||||
|
if (i == null) { return null; }
|
||||||
|
return sampleValue(i, which);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleValue(d, which as Number) as Numeric? {
|
||||||
|
if (which == 0) { return (d has :steps) ? d.steps : null; }
|
||||||
|
if (which == 1) { return (d has :calories) ? d.calories : null; }
|
||||||
|
if (which == 2) { return (d has :distance) ? d.distance : null; }
|
||||||
|
if (which == 3) { return (d has :floorsClimbed) ? d.floorsClimbed : null; }
|
||||||
|
if (which == 4) {
|
||||||
|
if (!(d has :activeMinutesDay) || d.activeMinutesDay == null) { return null; }
|
||||||
|
return d.activeMinutesDay.total;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 距本周一过了几天,本周累计据此决定往前取多少天。
|
||||||
|
// day_of_week 是 1=周日,所以周日要特判成 6。
|
||||||
|
function daysIntoWeek(g as Gregorian.Info) as Number {
|
||||||
|
var dow = g.day_of_week; // 1 = Sunday
|
||||||
|
return dow == 1 ? 6 : dow - 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayOfYear(g as Gregorian.Info) as Number {
|
||||||
|
var cum = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
||||||
|
var d = cum[g.month - 1] + g.day;
|
||||||
|
var y = g.year;
|
||||||
|
var leap = (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
|
||||||
|
if (leap && g.month > 2) { d = d + 1; }
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO 8601 周数:周一为一周之始,含当年第一个周四的那周为第 1 周。
|
||||||
|
function isoWeek(g as Gregorian.Info) as Number {
|
||||||
|
var dow = g.day_of_week - 1; // 0 = Sunday
|
||||||
|
var iso = dow == 0 ? 7 : dow; // 1 = Monday .. 7 = Sunday
|
||||||
|
var w = (dayOfYear(g) - iso + 10) / 7;
|
||||||
|
if (w < 1) { return 52; }
|
||||||
|
if (w > 52) { return 1; }
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 月龄:距 2000-01-06 那次新月的天数,对朔望月(29.530588853 天)取余。
|
||||||
|
function moonAge(g as Gregorian.Info) as Float {
|
||||||
|
var y = g.year;
|
||||||
|
var m = g.month;
|
||||||
|
if (m <= 2) { y = y - 1; m = m + 12; }
|
||||||
|
var a = y / 100;
|
||||||
|
var b = 2 - a + a / 4;
|
||||||
|
var jd = (365.25 * (y + 4716)).toNumber() + (30.6001 * (m + 1)).toNumber()
|
||||||
|
+ g.day + b - 1524.5 + g.hour / 24.0;
|
||||||
|
// ⚠️ Monkey C 的 % 不支持浮点,只能手写取余。
|
||||||
|
var syn = 29.530588853;
|
||||||
|
var x = jd - 2451550.1;
|
||||||
|
var age = x - (x / syn).toNumber() * syn;
|
||||||
|
if (age < 0) { age = age + syn; }
|
||||||
|
return age.toFloat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function windDir(bearingDeg as Numeric?) as String {
|
||||||
|
if (bearingDeg == null) { return DASH; }
|
||||||
|
var pts = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
|
||||||
|
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"];
|
||||||
|
var b = bearingDeg.toFloat();
|
||||||
|
while (b < 0) { b = b + 360.0; }
|
||||||
|
var i = ((b + 11.25) / 22.5).toNumber() % 16;
|
||||||
|
return pts[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------- 分类取值 ------------------------------
|
||||||
|
// ---- 日期时间类 ----
|
||||||
|
// 不属于本类的编号返回 null,交给 value() 试下一个分类。
|
||||||
|
function dateTime(id as Number, c as Ctx) as String? {
|
||||||
|
if (id == 860) { return clockInfo(c.greg, c); } // 时间
|
||||||
|
if (id == 880) { // UTC 时间
|
||||||
|
var utc = Gregorian.utcInfo(Time.now(), Time.FORMAT_SHORT);
|
||||||
|
return clockInfo(utc, c);
|
||||||
|
}
|
||||||
|
// 第二时区 1
|
||||||
|
if (id == 864) { return offsetClock(Settings.number("AltOffset1", 0), c); }
|
||||||
|
// 第二时区 2
|
||||||
|
if (id == 865) { return offsetClock(Settings.number("AltOffset2", 0), c); }
|
||||||
|
if (id == 855) { return c.greg.day.format("%d"); } // 日
|
||||||
|
if (id == 868) { return c.greg.month.format("%d"); } // 月份数字
|
||||||
|
if (id == 859) { return isoWeek(c.greg).format("%d"); } // 周数 (ISO)
|
||||||
|
// 周数
|
||||||
|
if (id == 873) { return ((dayOfYear(c.greg) + 6) / 7).format("%d"); }
|
||||||
|
// 秒数要动起来依赖设备支持 onPartialUpdate;
|
||||||
|
// view 只在有格子选了秒时才去请求那个回调。
|
||||||
|
if (id == 850) { return c.greg.sec.format("%d"); } // 秒
|
||||||
|
if (id == 869) { return c.greg.sec.format("%02d"); } // 秒 (前导零)
|
||||||
|
if (id == 870) { return dow(c.greg); } // 星期简写
|
||||||
|
if (id == 856) { return dow(c.greg); } // 星期全称
|
||||||
|
if (id == 853) { return month(c.greg); } // 月份
|
||||||
|
if (id == 852) { return monthDay(c.greg); } // 月 日
|
||||||
|
if (id == 872) { return dow(c.greg) + " " + dayOnly(c.greg); } // 星期 日
|
||||||
|
if (id == 851) { return dow(c.greg) + " " + monthDay(c.greg); } // 星期 月 日
|
||||||
|
if (id == 858) { return meridiem(c.greg); } // 上午/下午
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 活动类 ----
|
||||||
|
// 步数、卡路里、距离、楼层、活动分钟,以及它们的本周/近 7 天累计。
|
||||||
|
function activity(id as Number, c as Ctx) as String? {
|
||||||
|
var i = c.info();
|
||||||
|
if (id == 1) { return i == null ? DASH : num(i.steps); } // 步数
|
||||||
|
if (id == 14) { return i == null ? DASH : rounded(i.steps); } // 步数 (取整)
|
||||||
|
if (id == 707) { // 剩余步数
|
||||||
|
if (i == null || i.steps == null || i.stepGoal == null) { return DASH; }
|
||||||
|
var left = i.stepGoal - i.steps;
|
||||||
|
return left > 0 ? left.format("%d") : "0";
|
||||||
|
}
|
||||||
|
if (id == 2) { return i == null ? DASH : num(i.calories); } // 卡路里
|
||||||
|
if (id == 603) { // 活动卡路里
|
||||||
|
if (i == null || i.calories == null) { return DASH; }
|
||||||
|
var p = c.profile();
|
||||||
|
if (p == null || p.weight == null || p.height == null) { return num(i.calories); }
|
||||||
|
return num(i.calories); // device reports total only
|
||||||
|
}
|
||||||
|
if (id == 150 || id == 152 || id == 154) { // 活动分钟 / 中强度分钟 / 高强度分钟
|
||||||
|
if (i == null || !(i has :activeMinutesDay) || i.activeMinutesDay == null) {
|
||||||
|
return DASH;
|
||||||
|
}
|
||||||
|
var am = i.activeMinutesDay;
|
||||||
|
if (id == 152) { return num(am.moderate); } // 中强度分钟
|
||||||
|
if (id == 154) { return num(am.vigorous); } // 高强度分钟
|
||||||
|
return num(am.total);
|
||||||
|
}
|
||||||
|
if (id == 151) { // 本周活动分钟
|
||||||
|
if (i == null || !(i has :activeMinutesWeek) || i.activeMinutesWeek == null) {
|
||||||
|
return DASH;
|
||||||
|
}
|
||||||
|
return num(i.activeMinutesWeek.total);
|
||||||
|
}
|
||||||
|
// 距离
|
||||||
|
if (id == 3) { return i == null ? DASH : dist(i.distance, c); }
|
||||||
|
// 上楼层数
|
||||||
|
if (id == 4) { return i == null ? DASH : num(i.floorsClimbed); }
|
||||||
|
// ⚠️ ActivityMonitor.Info 上有些成员**在所有设备上都能编译通过,但只有
|
||||||
|
// 部分设备/固件运行时才真的有**。轮椅计数器就是典型:在 fenix 8 上直接抛
|
||||||
|
// "Symbol Not Found",用户一选这个字段表盘就崩。
|
||||||
|
// 所以凡是非通用成员,用之前一律先 has 检查。
|
||||||
|
if (id == 5) { // 下楼层数
|
||||||
|
return (i == null || !(i has :floorsDescended)) ? DASH
|
||||||
|
: num(i.floorsDescended);
|
||||||
|
}
|
||||||
|
if (id == 6) { // 爬升高度
|
||||||
|
return (i == null || !(i has :metersClimbed)) ? DASH
|
||||||
|
: metres(i.metersClimbed, c);
|
||||||
|
}
|
||||||
|
if (id == 9) { // 推行距离
|
||||||
|
return (i == null || !(i has :pushDistance)) ? DASH
|
||||||
|
: dist(i.pushDistance, c);
|
||||||
|
}
|
||||||
|
if (id == 10) { // 推行次数
|
||||||
|
return (i == null || !(i has :pushes)) ? DASH : num(i.pushes);
|
||||||
|
}
|
||||||
|
if (id == 219 || id == 211 || id == 213 || id == 203) { // 本周步数 / 本周卡路里 / 本周距离 / 本周楼层
|
||||||
|
var which = (id == 219) ? 0 : ((id == 211) ? 1 : ((id == 213) ? 2 : 3));
|
||||||
|
var back = daysIntoWeek(c.greg);
|
||||||
|
var v = sumHistory(c, which, back);
|
||||||
|
if (v == null) { return DASH; }
|
||||||
|
return (id == 213) ? dist(v, c) : rounded(v);
|
||||||
|
}
|
||||||
|
// 近 7 天步数 / 近 7 天卡路里 / 近 7 天距离 / 近 7 天楼层 / 近 7 天活动分钟
|
||||||
|
if (id == 220 || id == 212 || id == 214 || id == 204 || id == 200) {
|
||||||
|
var which = (id == 220) ? 0 : ((id == 212) ? 1
|
||||||
|
: ((id == 214) ? 2 : ((id == 204) ? 3 : 4)));
|
||||||
|
var v = sumHistory(c, which, 6);
|
||||||
|
if (v == null) { return DASH; }
|
||||||
|
return (id == 214) ? dist(v, c) : rounded(v);
|
||||||
|
}
|
||||||
|
if (id == 8 || id == 15) { // 活动条 / 活动条 (反向)
|
||||||
|
if (i == null || i.moveBarLevel == null) { return DASH; }
|
||||||
|
var lvl = i.moveBarLevel;
|
||||||
|
// 活动条 (反向)
|
||||||
|
if (id == 15) { lvl = ActivityMonitor.MOVE_BAR_LEVEL_MAX - lvl; }
|
||||||
|
return lvl.format("%d");
|
||||||
|
}
|
||||||
|
if (id == 13) { // 恢复时间
|
||||||
|
return (i == null || !(i has :timeToRecovery)) ? DASH
|
||||||
|
: num(i.timeToRecovery);
|
||||||
|
}
|
||||||
|
if (id == 11) { // 呼吸频率
|
||||||
|
return (i == null || !(i has :respirationRate)) ? DASH
|
||||||
|
: num(i.respirationRate);
|
||||||
|
}
|
||||||
|
if (id == 12) { // 压力指数
|
||||||
|
return (i == null || !(i has :stressScore)) ? DASH : num(i.stressScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 身体类 ----
|
||||||
|
// 心率、压力、血氧、身体电量,以及从用户档案推出来的体重/BMI/VO2max。
|
||||||
|
function body(id as Number, c as Ctx) as String? {
|
||||||
|
if (id == 618) { // 心率
|
||||||
|
var a = c.act();
|
||||||
|
if (a != null && a.currentHeartRate != null) { return num(a.currentHeartRate); }
|
||||||
|
var h = ActivityMonitor.getHeartRateHistory(1, true);
|
||||||
|
if (h != null) {
|
||||||
|
var s = h.next();
|
||||||
|
if (s != null && s.heartRate != null
|
||||||
|
&& s.heartRate != ActivityMonitor.INVALID_HR_SAMPLE) {
|
||||||
|
return num(s.heartRate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DASH;
|
||||||
|
}
|
||||||
|
if (id == 276) { // 静息心率
|
||||||
|
var p = c.profile();
|
||||||
|
return (p == null || !(p has :restingHeartRate)) ? DASH
|
||||||
|
: num(p.restingHeartRate);
|
||||||
|
}
|
||||||
|
if (id == 635) { // 血氧
|
||||||
|
if (!(SensorHistory has :getOxygenSaturationHistory)) { return DASH; }
|
||||||
|
return num(latest(SensorHistory.getOxygenSaturationHistory({:period => 1})));
|
||||||
|
}
|
||||||
|
if (id == 623) { // 身体电量
|
||||||
|
if (!(SensorHistory has :getBodyBatteryHistory)) { return DASH; }
|
||||||
|
return num(latest(SensorHistory.getBodyBatteryHistory({:period => 1})));
|
||||||
|
}
|
||||||
|
if (id == 281 || id == 742) { // 体重 / BMI
|
||||||
|
var p = c.profile();
|
||||||
|
if (p == null || !(p has :weight) || p.weight == null) { return DASH; }
|
||||||
|
var kg = p.weight.toFloat() / 1000.0;
|
||||||
|
if (id == 281) { // 体重
|
||||||
|
return (c.metric ? kg : kg * 2.20462).format("%.1f");
|
||||||
|
}
|
||||||
|
if (!(p has :height) || p.height == null || p.height <= 0) { return DASH; }
|
||||||
|
var m = p.height.toFloat() / 100.0;
|
||||||
|
return (kg / (m * m)).format("%.1f");
|
||||||
|
}
|
||||||
|
if (id == 277 || id == 278) { // 最大摄氧量 (骑) / 最大摄氧量 (跑)
|
||||||
|
var p = c.profile();
|
||||||
|
if (p == null) { return DASH; }
|
||||||
|
if (id == 278) { // 最大摄氧量 (跑)
|
||||||
|
return (p has :vo2maxRunning) ? num(p.vo2maxRunning) : DASH;
|
||||||
|
}
|
||||||
|
return (p has :vo2maxCycling) ? num(p.vo2maxCycling) : DASH;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 系统类 ----
|
||||||
|
// 电量、闹钟、通知、勿扰、蓝牙、太阳能强度。
|
||||||
|
function system(id as Number, c as Ctx) as String? {
|
||||||
|
var st = c.stats();
|
||||||
|
if (id == 250) { // 电量
|
||||||
|
return st == null || st.battery == null ? DASH
|
||||||
|
: st.battery.format("%d") + "%";
|
||||||
|
}
|
||||||
|
if (id == 251) { // 剩余天数
|
||||||
|
if (st == null || !(st has :batteryInDays) || st.batteryInDays == null) {
|
||||||
|
return DASH;
|
||||||
|
}
|
||||||
|
return st.batteryInDays.format("%d") + "d";
|
||||||
|
}
|
||||||
|
if (id == 257) { // 电量 / 天数
|
||||||
|
var pct = (st == null || st.battery == null) ? DASH : st.battery.format("%d");
|
||||||
|
var d = (st == null || !(st has :batteryInDays) || st.batteryInDays == null)
|
||||||
|
? DASH : st.batteryInDays.format("%d");
|
||||||
|
return pct + "/" + d;
|
||||||
|
}
|
||||||
|
if (id == 252) { // 太阳能强度
|
||||||
|
if (st == null || !(st has :solarIntensity) || st.solarIntensity == null) {
|
||||||
|
return DASH;
|
||||||
|
}
|
||||||
|
return st.solarIntensity.format("%d") + "%";
|
||||||
|
}
|
||||||
|
var ds = c.ds();
|
||||||
|
// 闹钟
|
||||||
|
if (id == 253) { return ds == null ? DASH : num(ds.alarmCount); }
|
||||||
|
// 通知
|
||||||
|
if (id == 255) { return ds == null ? DASH : num(ds.notificationCount); }
|
||||||
|
if (id == 254) { // 勿扰模式
|
||||||
|
return ds == null ? DASH : (ds.doNotDisturb ? "ON" : "OFF");
|
||||||
|
}
|
||||||
|
if (id == 256) { // 蓝牙
|
||||||
|
return ds == null ? DASH : (ds.phoneConnected ? "ON" : "OFF");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 环境类 ----
|
||||||
|
// 海拔、气压、各类太阳事件(含晨昏蒙影与黄金/蓝调时刻)、月相。
|
||||||
|
function environment(id as Number, c as Ctx) as String? {
|
||||||
|
if (id == 615) { // 海拔
|
||||||
|
var a = c.act();
|
||||||
|
return (a == null || !(a has :altitude)) ? DASH : metres(a.altitude, c);
|
||||||
|
}
|
||||||
|
if (id == 616 || id == 733) { // 海平面气压 / 环境气压
|
||||||
|
var a = c.act();
|
||||||
|
if (a == null) { return DASH; }
|
||||||
|
var pa = null;
|
||||||
|
if (id == 616 && (a has :meanSeaLevelPressure)) { // 海平面气压
|
||||||
|
pa = a.meanSeaLevelPressure;
|
||||||
|
} else if (id == 733 && (a has :ambientPressure)) { // 环境气压
|
||||||
|
pa = a.ambientPressure;
|
||||||
|
}
|
||||||
|
if (pa == null) { return DASH; }
|
||||||
|
return (pa.toFloat() / 100.0).format("%d");
|
||||||
|
}
|
||||||
|
if (id == 712 || id == 717 || id == 700 || id == 731) { // 日出 / 日落 / 下一太阳事件 / 距太阳事件
|
||||||
|
var loc = c.where();
|
||||||
|
if (loc == null) { return DASH; }
|
||||||
|
var now = Time.now();
|
||||||
|
var rise = Weather.getSunrise(loc, now);
|
||||||
|
var set = Weather.getSunset(loc, now);
|
||||||
|
if (id == 712) { return clock(rise, c); } // 日出
|
||||||
|
if (id == 717) { return clock(set, c); } // 日落
|
||||||
|
if (rise == null || set == null) { return DASH; }
|
||||||
|
var next = (now.compare(rise) < 0) ? rise
|
||||||
|
: ((now.compare(set) < 0) ? set : rise);
|
||||||
|
if (id == 700) { return clock(next, c); } // 下一太阳事件
|
||||||
|
var secs = next.compare(now);
|
||||||
|
if (secs < 0) { secs = secs + 86400; }
|
||||||
|
return (secs / 3600).format("%d") + "h" + ((secs % 3600) / 60).format("%02d");
|
||||||
|
}
|
||||||
|
if (Solar.owns(id)) {
|
||||||
|
return clock(Solar.byId(id, c.where()), c);
|
||||||
|
}
|
||||||
|
if (id == 706) { return moonAge(c.greg).format("%.1f"); } // 月龄
|
||||||
|
if (id == 732) { // 月球照明
|
||||||
|
var age = moonAge(c.greg);
|
||||||
|
var lit = (1.0 - Math.cos(2.0 * Math.PI * age / 29.530588853)) / 2.0;
|
||||||
|
return (lit * 100.0).format("%d") + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Garmin 原生天气 ----
|
||||||
|
// 手表同步下来的当前天气与今日预报。与 OWM 那套完全独立。
|
||||||
|
function weather(id as Number, c as Ctx) as String? {
|
||||||
|
var w = c.wx();
|
||||||
|
// 气温
|
||||||
|
if (id == 306) { return w == null ? DASH : temp(w.temperature, c); }
|
||||||
|
if (id == 300) { // 体感温度
|
||||||
|
return (w == null || !(w has :feelsLikeTemperature)) ? DASH
|
||||||
|
: temp(w.feelsLikeTemperature, c);
|
||||||
|
}
|
||||||
|
if (id == 305) { // 湿度
|
||||||
|
return (w == null || !(w has :relativeHumidity)) ? DASH
|
||||||
|
: num(w.relativeHumidity) + "%";
|
||||||
|
}
|
||||||
|
if (id == 304) { // 降水概率
|
||||||
|
if (w == null || !(w has :precipitationChance)) { return DASH; }
|
||||||
|
return num(w.precipitationChance) + "%";
|
||||||
|
}
|
||||||
|
if (id == 308) { // 风速
|
||||||
|
if (w == null || !(w has :windSpeed) || w.windSpeed == null) { return DASH; }
|
||||||
|
var kmh = w.windSpeed.toFloat() * 3.6;
|
||||||
|
return (c.metric ? kmh : kmh * 0.621371).format("%d");
|
||||||
|
}
|
||||||
|
if (id == 307) { // 风向角
|
||||||
|
return (w == null || !(w has :windBearing)) ? DASH : num(w.windBearing);
|
||||||
|
}
|
||||||
|
if (id == 310) { // 风向
|
||||||
|
return (w == null || !(w has :windBearing)) ? DASH : windDir(w.windBearing);
|
||||||
|
}
|
||||||
|
if (id == 309) { // 天气更新时间
|
||||||
|
if (w == null || !(w has :observationTime) || w.observationTime == null) {
|
||||||
|
return DASH;
|
||||||
|
}
|
||||||
|
return clock(w.observationTime, c);
|
||||||
|
}
|
||||||
|
if (id == 312) { // 海平面气压
|
||||||
|
if (w == null || !(w has :pressure) || w.pressure == null) { return DASH; }
|
||||||
|
return (w.pressure.toFloat() / 100.0).format("%d");
|
||||||
|
}
|
||||||
|
if (id == 313) { // 紫外线指数
|
||||||
|
return (w == null || !(w has :uvIndex)) ? DASH : num(w.uvIndex);
|
||||||
|
}
|
||||||
|
if (id == 314) { // 能见度
|
||||||
|
if (w == null || !(w has :visibility) || w.visibility == null) { return DASH; }
|
||||||
|
var km = w.visibility.toFloat() / 1000.0;
|
||||||
|
return (c.metric ? km : km * 0.621371).format("%.1f");
|
||||||
|
}
|
||||||
|
if (id == 315) { // 露点
|
||||||
|
// CIQ 没有露点字段,用 Magnus 公式从温度和湿度反推,
|
||||||
|
// 误差在零点几度以内,够用。
|
||||||
|
if (w == null || w.temperature == null
|
||||||
|
|| !(w has :relativeHumidity) || w.relativeHumidity == null) {
|
||||||
|
return DASH;
|
||||||
|
}
|
||||||
|
var t = w.temperature.toFloat();
|
||||||
|
var rh = w.relativeHumidity.toFloat();
|
||||||
|
if (rh <= 0.0) { return DASH; }
|
||||||
|
var g2 = (17.62 * t) / (243.12 + t) + Math.ln(rh / 100.0);
|
||||||
|
return temp(243.12 * g2 / (17.62 - g2), c);
|
||||||
|
}
|
||||||
|
if (id == 301 || id == 302 || id == 639) { // 最高气温 / 最低气温 / 最高/最低气温
|
||||||
|
var f = c.forecast();
|
||||||
|
if (f == null) { return DASH; }
|
||||||
|
var hi = (f has :highTemperature) ? f.highTemperature : null;
|
||||||
|
var lo = (f has :lowTemperature) ? f.lowTemperature : null;
|
||||||
|
if (id == 301) { return temp(hi, c); } // 最高气温
|
||||||
|
if (id == 302) { return temp(lo, c); } // 最低气温
|
||||||
|
return temp(hi, c) + "/" + temp(lo, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- OpenWeatherMap ----
|
||||||
|
// 读后台服务上次存下来的数据(本函数不联网,见 Owm.mc / Fenix8V3Background.mc)。
|
||||||
|
// 后台一律用公制请求,单位换算和其他字段一样在这里做。
|
||||||
|
function owm(id as Number, c as Ctx) as String? {
|
||||||
|
if (!Owm.owns(id)) { return null; }
|
||||||
|
if (!Owm.enabled()) { return DASH; }
|
||||||
|
if (id == 500) { return temp(Owm.get("temp"), c); } // OWM 气温
|
||||||
|
if (id == 503) { return temp(Owm.get("feels"), c); } // OWM 体感温度
|
||||||
|
if (id == 504) { return temp(Owm.get("tmax"), c); } // OWM 最高温
|
||||||
|
if (id == 505) { return temp(Owm.get("tmin"), c); } // OWM 最低温
|
||||||
|
if (id == 515) { // OWM 最高/最低
|
||||||
|
return temp(Owm.get("tmax"), c) + "/" + temp(Owm.get("tmin"), c);
|
||||||
|
}
|
||||||
|
if (id == 507) { return str(Owm.get("short")); } // OWM 天气
|
||||||
|
if (id == 502) { return str(Owm.get("long")); } // OWM 天气详述
|
||||||
|
if (id == 501) { // OWM 湿度
|
||||||
|
var h = Owm.get("humidity");
|
||||||
|
return h == null ? DASH : num(h) + "%";
|
||||||
|
}
|
||||||
|
if (id == 506) { return num(Owm.get("pressure")); } // OWM 气压
|
||||||
|
if (id == 509) { // OWM 能见度
|
||||||
|
var v = Owm.get("visibility");
|
||||||
|
if (v == null) { return DASH; }
|
||||||
|
var km = v.toFloat() / 1000.0;
|
||||||
|
return (c.metric ? km : km * 0.621371).format("%.1f");
|
||||||
|
}
|
||||||
|
if (id == 510 || id == 512) { // OWM 风速 / OWM 阵风
|
||||||
|
var w = Owm.get(id == 510 ? "wind" : "gust");
|
||||||
|
if (w == null) { return DASH; }
|
||||||
|
var kmh = w.toFloat() * 3.6;
|
||||||
|
return (c.metric ? kmh : kmh * 0.621371).format("%d");
|
||||||
|
}
|
||||||
|
if (id == 511) { return num(Owm.get("winddeg")); } // OWM 风向角
|
||||||
|
if (id == 518) { return windDir(Owm.get("winddeg")); } // OWM 风向
|
||||||
|
if (id == 513) { // OWM 1h 降雨
|
||||||
|
var r = Owm.get("rain");
|
||||||
|
return r == null ? "0" : r.toFloat().format("%.1f");
|
||||||
|
}
|
||||||
|
if (id == 532) { // OWM 1h 降雪
|
||||||
|
var sn = Owm.get("snow");
|
||||||
|
return sn == null ? "0" : sn.toFloat().format("%.1f");
|
||||||
|
}
|
||||||
|
if (id == 536) { // OWM 云量
|
||||||
|
var cl = Owm.get("clouds");
|
||||||
|
return cl == null ? DASH : num(cl) + "%";
|
||||||
|
}
|
||||||
|
if (id == 537) { // OWM 降水概率
|
||||||
|
var pop = Owm.get("pop");
|
||||||
|
return pop == null ? DASH : (pop.toFloat() * 100.0).format("%d") + "%";
|
||||||
|
}
|
||||||
|
if (id == 530) { // OWM 露点
|
||||||
|
// OWM 免费接口不返回露点,同样用 Magnus 公式从温湿度算。
|
||||||
|
var t = Owm.get("temp");
|
||||||
|
var rh = Owm.get("humidity");
|
||||||
|
if (t == null || rh == null || rh.toFloat() <= 0.0) { return DASH; }
|
||||||
|
var tf = t.toFloat();
|
||||||
|
var gg = (17.62 * tf) / (243.12 + tf) + Math.ln(rh.toFloat() / 100.0);
|
||||||
|
return temp(243.12 * gg / (17.62 - gg), c);
|
||||||
|
}
|
||||||
|
if (id == 534) { return temp(Owm.get("hi1"), c); } // OWM 明日气温
|
||||||
|
if (id == 535) { return temp(Owm.get("hi2"), c); } // OWM 后日气温
|
||||||
|
if (id == 522) { // OWM 明日高低温
|
||||||
|
return temp(Owm.get("hi1"), c) + "/" + temp(Owm.get("lo1"), c);
|
||||||
|
}
|
||||||
|
if (id == 523) { // OWM 后日高低温
|
||||||
|
return temp(Owm.get("hi2"), c) + "/" + temp(Owm.get("lo2"), c);
|
||||||
|
}
|
||||||
|
if (id == 516) { return str(Owm.get("city")); } // OWM 城市
|
||||||
|
if (id == 517) { // OWM 更新时间
|
||||||
|
var dt = Owm.get("dt");
|
||||||
|
return dt == null ? DASH : clock(new Time.Moment(dt as Number), c);
|
||||||
|
}
|
||||||
|
return DASH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(v) as String {
|
||||||
|
return v == null ? DASH : v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 自定义文字 ----
|
||||||
|
// 用户在设置里自己填的三段文字。
|
||||||
|
function custom(id as Number, c as Ctx) as String? {
|
||||||
|
if (id == 703) { return Settings.text("Custom1"); } // 自定义文字 1
|
||||||
|
if (id == 704) { return Settings.text("Custom2"); } // 自定义文字 2
|
||||||
|
if (id == 705) { return Settings.text("Custom3"); } // 自定义文字 3
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取某个字段的显示文本。
|
||||||
|
// 按分类依次尝试,而不是按编号区间派发 —— 编号是交错的(1/2/3 活动、
|
||||||
|
// 8/11/12 身体、14 又是活动),区间派发行不通。
|
||||||
|
function value(id as Number, c as Ctx) as String {
|
||||||
|
var v = dateTime(id, c);
|
||||||
|
if (v == null) { v = activity(id, c); }
|
||||||
|
if (v == null) { v = body(id, c); }
|
||||||
|
if (v == null) { v = system(id, c); }
|
||||||
|
if (v == null) { v = environment(id, c); }
|
||||||
|
if (v == null) { v = weather(id, c); }
|
||||||
|
if (v == null) { v = owm(id, c); }
|
||||||
|
if (v == null) { v = custom(id, c); }
|
||||||
|
return v == null ? "" : v;
|
||||||
|
}
|
||||||
|
}
|
||||||
155
source/Icons.mc
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import Toybox.Lang;
|
||||||
|
import Toybox.Graphics;
|
||||||
|
import Toybox.WatchUi;
|
||||||
|
import Layout;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 图标
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 顶部两个数据位上方的图标,加上电池和蓝牙符号。
|
||||||
|
//
|
||||||
|
// 设计稿里只有三个图标:日出、天气、电池。
|
||||||
|
//
|
||||||
|
// 前两个是位图描出来的矢量轮廓,最细的特征只有 2–3 个设计像素宽。一开始我用
|
||||||
|
// 圆、线段、多边形去「重画」它们,在 454px 上看着还行,一到 280px(Enduro 3)
|
||||||
|
// 就糊成一团 —— 因为 s=0.56 时那些细节不足 1 个物理像素。
|
||||||
|
// 中间还试过做一版「低细节」图元,结果更糟:日出变成三条横杠。
|
||||||
|
//
|
||||||
|
// 现在的做法是:用 tools/gen_icons.py 把**设计稿的路径**按每种屏宽各光栅化一
|
||||||
|
// 张位图,运行时用 drawBitmap2(:tintColor) 按主题色染色。这样每台设备拿到的
|
||||||
|
// 都是为它的像素网格专门渲染的图。
|
||||||
|
//
|
||||||
|
// 其余图标(步数、卡路里、楼梯、心形…)设计稿里根本没有,所以保持图元绘制,
|
||||||
|
// 尺寸同样基于 500 设计画布。
|
||||||
|
// ============================================================================
|
||||||
|
module Icons {
|
||||||
|
|
||||||
|
// 三张位图在 onLayout 时一次性加载(loadResource 开销不小,不能放 onUpdate)
|
||||||
|
var mSunrise = null;
|
||||||
|
var mSunset = null;
|
||||||
|
var mWeather = null;
|
||||||
|
|
||||||
|
// 加载图标位图。资源按屏宽分目录,monkey.jungle 里为每台设备指定了
|
||||||
|
// resources-icons-<屏宽>,所以这里拿到的自动就是对的尺寸。
|
||||||
|
function load() as Void {
|
||||||
|
mSunrise = WatchUi.loadResource(Rez.Drawables.IconSunrise);
|
||||||
|
mSunset = WatchUi.loadResource(Rez.Drawables.IconSunset);
|
||||||
|
mWeather = WatchUi.loadResource(Rez.Drawables.IconWeather);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready() as Boolean { return mSunrise != null; }
|
||||||
|
|
||||||
|
// ⚠️ drawBitmap2 的 :tintColor 拒绝**调色板化**的位图,运行时会抛
|
||||||
|
// "Source must not use a color palette"。所以生成的 drawables.xml 里每个
|
||||||
|
// bitmap 都带 packingFormat="png",保住 alpha 通道不被压成调色板。
|
||||||
|
function blit(dc as Graphics.Dc, bmp, cxDesign as Numeric, col as Number) as Void {
|
||||||
|
if (bmp == null) { return; }
|
||||||
|
dc.drawBitmap2(Layout.px(cxDesign) - bmp.getWidth() / 2,
|
||||||
|
Layout.px(Layout.ICON_CY) - bmp.getHeight() / 2,
|
||||||
|
bmp, {:tintColor => col});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 电池:外框 + 内部电量条 + 右侧触点。
|
||||||
|
// 电量条长度跟随真实电量(设计稿画的是 40% 的状态)。
|
||||||
|
// 画法是「先整块填充,再用黑色掏空内部,最后按比例填回电量条」。
|
||||||
|
function battery(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
|
||||||
|
k as Float, col as Number, level as Float) as Void {
|
||||||
|
var x0 = Layout.px(x);
|
||||||
|
var y0 = Layout.px(cyDesign - Layout.BATT_H * k / 2);
|
||||||
|
var w = Layout.px(Layout.BATT_W * k);
|
||||||
|
var h = Layout.px(Layout.BATT_H * k);
|
||||||
|
var wall = Layout.px(Layout.BATT_WALL * k);
|
||||||
|
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.fillRoundedRectangle(x0, y0, w, h, Layout.px(3.4 * k));
|
||||||
|
dc.fillRectangle(x0 + w, Layout.px(cyDesign - Layout.BATT_TH * k / 2),
|
||||||
|
Layout.px(Layout.BATT_TW * k), Layout.px(Layout.BATT_TH * k));
|
||||||
|
var iw = w - 2 * wall;
|
||||||
|
var ih = h - 2 * wall;
|
||||||
|
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.fillRectangle(x0 + wall, y0 + wall, iw, ih);
|
||||||
|
var fill = (iw * level).toNumber();
|
||||||
|
if (fill > 0) {
|
||||||
|
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.fillRectangle(x0 + wall, y0 + wall, fill, ih);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标准蓝牙符号,用一条折线一笔画成(六段,中间的竖线会被走两次)。
|
||||||
|
function bluetooth(dc as Graphics.Dc, col as Number) as Void {
|
||||||
|
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.setPenWidth(Layout.pen(3.2));
|
||||||
|
var l = Layout.BT_CX - Layout.BT_HW;
|
||||||
|
var r = Layout.BT_CX + Layout.BT_HW;
|
||||||
|
var t = Layout.BT_CY - Layout.BT_HH;
|
||||||
|
var b = Layout.BT_CY + Layout.BT_HH;
|
||||||
|
var qt = Layout.BT_CY - Layout.BT_HH / 2;
|
||||||
|
var qb = Layout.BT_CY + Layout.BT_HH / 2;
|
||||||
|
Layout.line(dc, l, qt, r, qb);
|
||||||
|
Layout.line(dc, r, qb, Layout.BT_CX, b);
|
||||||
|
Layout.line(dc, Layout.BT_CX, b, Layout.BT_CX, t);
|
||||||
|
Layout.line(dc, Layout.BT_CX, t, r, qt);
|
||||||
|
Layout.line(dc, r, qt, l, qb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按字段编号决定上方画哪个图标。
|
||||||
|
// 日期、周数、自定义文字这类字段没有合适的图标,就不画 —— 光有数值也读得懂,
|
||||||
|
// 硬配一个反而干扰。
|
||||||
|
function forField(dc as Graphics.Dc, id as Number, xDesign as Numeric,
|
||||||
|
col as Number, level as Float) as Void {
|
||||||
|
if (id == 712 || id == 700) {
|
||||||
|
blit(dc, mSunrise, xDesign, col);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (id == 717 || id == 731) {
|
||||||
|
blit(dc, mSunset, xDesign, col);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (id == 306 || id == 639 || id == 300 || id == 301 || id == 302
|
||||||
|
|| id == 304 || id == 305 || id == 307 || id == 308
|
||||||
|
|| id == 309 || id == 310) {
|
||||||
|
blit(dc, mWeather, xDesign, col);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (id == 250 || id == 251 || id == 257) {
|
||||||
|
battery(dc, xDesign - 14.4, Layout.ICON_CY, 1.0, col, level);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var r = 13.0;
|
||||||
|
var cyd = Layout.ICON_CY;
|
||||||
|
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.setPenWidth(Layout.pen(3.0));
|
||||||
|
if (id == 2 || id == 603) { // 卡路里:火焰
|
||||||
|
dc.fillPolygon([[Layout.px(xDesign), Layout.px(cyd - r)],
|
||||||
|
[Layout.px(xDesign + r * 0.72), Layout.px(cyd + r * 0.25)],
|
||||||
|
[Layout.px(xDesign + r * 0.40), Layout.px(cyd + r * 0.85)],
|
||||||
|
[Layout.px(xDesign - r * 0.40), Layout.px(cyd + r * 0.85)],
|
||||||
|
[Layout.px(xDesign - r * 0.72), Layout.px(cyd + r * 0.25)]]);
|
||||||
|
} else if (id == 1 || id == 14 || id == 707) { // 步数:两个脚印
|
||||||
|
dc.fillRoundedRectangle(Layout.px(xDesign - r * 0.85), Layout.px(cyd - r * 0.75),
|
||||||
|
Layout.px(r * 0.62), Layout.px(r * 1.15),
|
||||||
|
Layout.px(r * 0.3));
|
||||||
|
dc.fillRoundedRectangle(Layout.px(xDesign + r * 0.22), Layout.px(cyd - r * 0.35),
|
||||||
|
Layout.px(r * 0.62), Layout.px(r * 1.15),
|
||||||
|
Layout.px(r * 0.3));
|
||||||
|
} else if (id == 3 || id == 9 || id == 615) { // 距离/海拔:箭头
|
||||||
|
dc.fillPolygon([[Layout.px(xDesign), Layout.px(cyd - r)],
|
||||||
|
[Layout.px(xDesign + r * 0.75), Layout.px(cyd + r * 0.8)],
|
||||||
|
[Layout.px(xDesign), Layout.px(cyd + r * 0.35)],
|
||||||
|
[Layout.px(xDesign - r * 0.75), Layout.px(cyd + r * 0.8)]]);
|
||||||
|
} else if (id == 618 || id == 276 || id == 623) { // 心率/静息/体能:心形
|
||||||
|
dc.fillCircle(Layout.px(xDesign - r * 0.38), Layout.px(cyd - r * 0.28),
|
||||||
|
Layout.px(r * 0.44));
|
||||||
|
dc.fillCircle(Layout.px(xDesign + r * 0.38), Layout.px(cyd - r * 0.28),
|
||||||
|
Layout.px(r * 0.44));
|
||||||
|
dc.fillPolygon([[Layout.px(xDesign - r * 0.78), Layout.px(cyd - r * 0.12)],
|
||||||
|
[Layout.px(xDesign + r * 0.78), Layout.px(cyd - r * 0.12)],
|
||||||
|
[Layout.px(xDesign), Layout.px(cyd + r * 0.85)]]);
|
||||||
|
} else if (id == 4 || id == 5 || id == 6) { // 楼层:台阶
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
dc.fillRectangle(Layout.px(xDesign - r * 0.85 + r * 0.58 * i),
|
||||||
|
Layout.px(cyd + r * 0.6 - r * 0.55 * i),
|
||||||
|
Layout.px(r * 0.62), Layout.px(r * 0.4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
247
source/Layout.mc
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import Toybox.Lang;
|
||||||
|
import Toybox.Graphics;
|
||||||
|
import Toybox.Math;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 布局与文字绘制
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 本文件里所有的数字都是**设计稿 500×500 画布上的坐标**,全部来自
|
||||||
|
// design/watchface-*.svg 的实测:
|
||||||
|
// - 进度环的几何参数取自 design/rebuild.py(原作者从位图逆向出的常量)
|
||||||
|
// - 各元素位置取自 SVG 描边路径的墨迹包围盒,不是目测的
|
||||||
|
//
|
||||||
|
// 绘制时统一乘以 s = 屏宽 / 500,所以同一套数字可以覆盖
|
||||||
|
// 240px(fenix 7S)到 454px(venu 3)全部 17 款设备。
|
||||||
|
//
|
||||||
|
// ⚠️ 使用前必须先由 view 的 onLayout 调用一次 init()。
|
||||||
|
// ============================================================================
|
||||||
|
module Layout {
|
||||||
|
|
||||||
|
// ------------------------------- 进度环 -------------------------------
|
||||||
|
// 刻度是**等角宽的环形扇区**,不是从圆心发射的线段 —— 所以画的时候用四点
|
||||||
|
// 多边形填充,而不是 drawLine 描边。这点很关键:用线段画会导致刻度在内圈
|
||||||
|
// 挤、外圈疏,与设计稿对不上。
|
||||||
|
const R_IN = 231.09; // 刻度带内边缘半径
|
||||||
|
const R_OUT = 249.10; // 刻度带外边缘半径
|
||||||
|
const TOP_N = 21; // 每条顶弧的刻度数
|
||||||
|
const TOP_PH = 6.500; // 第 0 根刻度的角度(自 12 点起顺时针,度)
|
||||||
|
const TOP_PITCH = 4.000;
|
||||||
|
const TOP_HW = 1.554; // 刻度角宽 3.108° 的一半
|
||||||
|
const BOT_N = 21; // 每条底弧的刻度数
|
||||||
|
const BOT_PH = 6.005; // 底弧角度自 6 点起量
|
||||||
|
const BOT_PITCH = 3.000;
|
||||||
|
const BOT_HW = 1.043;
|
||||||
|
const DOT_RAD = 240.10; // 四个锚点圆点所在半径(正好是刻度带中线)
|
||||||
|
const DOT_R = 9.00;
|
||||||
|
const DOT_ANG = [0.0, 93.15, 180.0, 266.85];
|
||||||
|
|
||||||
|
// ------------------------------- 各元素 -------------------------------
|
||||||
|
const BATT_X = 192.70; // 电池外框左边缘
|
||||||
|
const BATT_W = 26.20; // 只是外框宽度,右侧的触点另算
|
||||||
|
const BATT_H = 17.81;
|
||||||
|
const BATT_WALL = 2.20; // 外框线宽
|
||||||
|
const BATT_TW = 2.53; // 右侧触点宽
|
||||||
|
const BATT_TH = 9.20;
|
||||||
|
const BATT_VX = 232.00; // 百分比文字左边缘
|
||||||
|
const BATT_CY = 53.20;
|
||||||
|
|
||||||
|
const LEFT_X = 157.00; // 顶部两个数据位的中心 x(居中对齐)
|
||||||
|
const RIGHT_X = 343.70;
|
||||||
|
const ICON_CY = 89.50; // 与图标位图自身的中心对齐,别随意改
|
||||||
|
const VALUE_CY = 131.06;
|
||||||
|
|
||||||
|
const TIME_CY = 221.10;
|
||||||
|
const HOURS_R = 211.40; // 小时数字右边缘(右对齐基准)
|
||||||
|
const MINUTES_L = 276.02; // 分钟数字左边缘(左对齐基准)
|
||||||
|
const COLON_X = 237.88;
|
||||||
|
const COLON_W = 24.24;
|
||||||
|
const COLON_H = 21.30;
|
||||||
|
const COLON_Y1 = 183.68;
|
||||||
|
const COLON_Y2 = 234.57;
|
||||||
|
|
||||||
|
const BAND_Y = 286.30;
|
||||||
|
const BAND_H = 44.19;
|
||||||
|
const BAND_CY = 308.10;
|
||||||
|
const BT_CX = 36.88; // 蓝牙符号中心
|
||||||
|
const BT_CY = 306.99;
|
||||||
|
const BT_HW = 8.89;
|
||||||
|
const BT_HH = 12.92;
|
||||||
|
const DOW_CX = 178.05;
|
||||||
|
const MD_CX = 298.28;
|
||||||
|
const AMPM_R = 465.89;
|
||||||
|
|
||||||
|
const BOT_VAL_CY = 366.76;
|
||||||
|
const BOT_LAB_CY = 410.88;
|
||||||
|
const DIST_R = 234.30; // 左下格(数值+标签)的右对齐基准
|
||||||
|
const STEP_L = 267.40; // 右下格(数值+标签)的左对齐基准
|
||||||
|
|
||||||
|
// 设计稿里三档文字的**字面高度**(cap height,大写字母/数字的高度,
|
||||||
|
// 不含上下留白)。字号选择就是按这个来匹配的。
|
||||||
|
const CAP_BAND = 25.34; // 日期带、电量百分比
|
||||||
|
const CAP_COMP = 31.50; // 四个数据位、底部行
|
||||||
|
const CAP_TIME = 98.70; // 时间大数字
|
||||||
|
|
||||||
|
// 数值最多占多宽,超了就降一档字号。
|
||||||
|
// 顶部两格是居中对齐,这个值是它能横跨的总宽度;
|
||||||
|
// 底部两格是边对齐,各占表盘中间往外的一半。
|
||||||
|
// 有了这个,用户就算把 6 个汉字的长日期放进底部格也不会压到进度环上。
|
||||||
|
const TOP_MAX_W = 185.0;
|
||||||
|
const BOT_MAX_W = 150.0;
|
||||||
|
|
||||||
|
// ⚠️ Connect IQ 只暴露 ascent / descent / fontHeight,**没有字面高度接口**。
|
||||||
|
// 而 ascent 里包含了数字和大写字母永远用不到的变音符号空间,直接拿 ascent
|
||||||
|
// 当字面高度会导致选出来的字号明显偏小(第一版就是这么错的,时间数字只有
|
||||||
|
// 设计稿的 57%)。
|
||||||
|
//
|
||||||
|
// 下面两个比例是**实测**出来的:在模拟器里渲染,然后从截图上量墨迹高度。
|
||||||
|
// fenix847mm:FONT_XTINY ascent 29 → 大写实际高 22px → 0.76
|
||||||
|
// FONT_NUMBER_MILD ascent 82 → 数字实际高 58px → 0.71
|
||||||
|
// 各机型内置字体不完全相同(Roboto / RobotoCondensed / Yantramanav /
|
||||||
|
// 各机型位图字体),比例会有几个百分点误差,可以接受。
|
||||||
|
const CAP_OF_TEXT = 0.76;
|
||||||
|
const CAP_OF_NUM = 0.71;
|
||||||
|
|
||||||
|
// ------------------------------- 运行时状态 ----------------------------
|
||||||
|
var scale = 1.0; // 设备像素 / 设计像素
|
||||||
|
var width = 0;
|
||||||
|
var height = 0;
|
||||||
|
var cx = 0;
|
||||||
|
var cy = 0;
|
||||||
|
var fontBand = Graphics.FONT_SMALL;
|
||||||
|
var fontComp = Graphics.FONT_MEDIUM;
|
||||||
|
var fontTime = Graphics.FONT_NUMBER_HOT;
|
||||||
|
|
||||||
|
// 文字字号按从小到大排列,这样放不下的值可以沿着这张表往下逐档缩。
|
||||||
|
var textFonts = [Graphics.FONT_XTINY, Graphics.FONT_TINY, Graphics.FONT_SMALL,
|
||||||
|
Graphics.FONT_MEDIUM, Graphics.FONT_LARGE];
|
||||||
|
|
||||||
|
function init(dc as Graphics.Dc) as Void {
|
||||||
|
width = dc.getWidth();
|
||||||
|
height = dc.getHeight();
|
||||||
|
scale = width / 500.0;
|
||||||
|
cx = width / 2;
|
||||||
|
cy = height / 2;
|
||||||
|
var nums = [Graphics.FONT_NUMBER_MILD, Graphics.FONT_NUMBER_MEDIUM,
|
||||||
|
Graphics.FONT_NUMBER_HOT, Graphics.FONT_NUMBER_THAI_HOT];
|
||||||
|
fontBand = pickFont(textFonts, CAP_BAND * scale, CAP_OF_TEXT);
|
||||||
|
fontComp = pickFont(textFonts, CAP_COMP * scale, CAP_OF_TEXT);
|
||||||
|
fontTime = pickFont(nums, CAP_TIME * scale, CAP_OF_NUM);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready() as Boolean { return width > 0; }
|
||||||
|
|
||||||
|
// 设计像素 → 设备像素。整个文件里凡是坐标都要过这一层。
|
||||||
|
function px(v as Numeric) as Number {
|
||||||
|
return Math.round(v * scale).toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
function capHeight(font as Graphics.FontDefinition, ratio as Float) as Float {
|
||||||
|
return Graphics.getFontAscent(font) * ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选**字面高度最接近**目标的那一档,而不是「不超过目标的最大档」。
|
||||||
|
// 后者在设计尺寸恰好卡在两档字体之间时会白白小一号。
|
||||||
|
function pickFont(cands as Array<Graphics.FontDefinition>, targetCap as Float,
|
||||||
|
ratio as Float) as Graphics.FontDefinition {
|
||||||
|
var best = cands[0];
|
||||||
|
var bestErr = -1.0;
|
||||||
|
for (var i = 0; i < cands.size(); i++) {
|
||||||
|
var err = capHeight(cands[i], ratio) - targetCap;
|
||||||
|
if (err < 0) { err = -err; }
|
||||||
|
if (bestErr < 0 || err < bestErr) { bestErr = err; best = cands[i]; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 值太长时(比如 12 个字符的自定义文字、6 个汉字的长日期)按设计稿字号会
|
||||||
|
// 冲出表盘。这里沿字号表往下找,直到能塞进槽位宽度为止。
|
||||||
|
function fitFont(dc as Graphics.Dc, str as String,
|
||||||
|
font as Graphics.FontDefinition, maxDesignW as Float)
|
||||||
|
as Graphics.FontDefinition {
|
||||||
|
var limit = px(maxDesignW);
|
||||||
|
var f = font;
|
||||||
|
var i = textFonts.size() - 1;
|
||||||
|
while (i >= 0) {
|
||||||
|
if (textFonts[i] == f) { break; }
|
||||||
|
i--;
|
||||||
|
}
|
||||||
|
if (i < 0) { return f; } // 不是文字字号表里的(比如数字字体),不动它
|
||||||
|
while (i >= 0) {
|
||||||
|
if (dc.getTextWidthInPixels(str, textFonts[i]) <= limit) {
|
||||||
|
return textFonts[i];
|
||||||
|
}
|
||||||
|
i--;
|
||||||
|
}
|
||||||
|
return textFonts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEXT_JUSTIFY_VCENTER 居中的是**字体盒子**,而大写字母/数字只占据
|
||||||
|
// 「基线往上 capHeight」这一段,在盒子里是偏上的。
|
||||||
|
// 所以要往下推一个差值,才能让实际墨迹的中心落在 cy 上。
|
||||||
|
function inkY(dc as Graphics.Dc, font as Graphics.FontDefinition,
|
||||||
|
cyDesign as Numeric, ratio as Float) as Number {
|
||||||
|
var fh = dc.getFontHeight(font);
|
||||||
|
var asc = Graphics.getFontAscent(font);
|
||||||
|
return px(cyDesign) + ((fh / 2.0) - asc + capHeight(font, ratio) / 2.0).toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
|
||||||
|
font as Graphics.FontDefinition, str as String,
|
||||||
|
just as Number, col as Number) as Void {
|
||||||
|
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.drawText(px(x), inkY(dc, font, cyDesign, CAP_OF_TEXT), font, str,
|
||||||
|
just | Graphics.TEXT_JUSTIFY_VCENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同上,但会在字符串超出槽位宽度时自动降字号。
|
||||||
|
function fittedText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
|
||||||
|
font as Graphics.FontDefinition, str as String,
|
||||||
|
just as Number, col as Number, maxDesignW as Float) as Void {
|
||||||
|
text(dc, x, cyDesign, fitFont(dc, str, font, maxDesignW), str, just, col);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monkey C 没法给文字填渐变。这里的做法是:**同一串数字画 N 遍**,每遍用
|
||||||
|
// dc.setClip 把绘制限制在一条横向色带内,色带颜色取自主题的渐变停止点。
|
||||||
|
//
|
||||||
|
// 首尾两条色带故意向外延伸到屏幕边缘,保证字形上下不会有没画到的缝隙
|
||||||
|
// (字体实际墨迹高度和我们估算的 capHeight 总有几像素出入)。
|
||||||
|
function gradText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
|
||||||
|
font as Graphics.FontDefinition, str as String,
|
||||||
|
just as Number, ramp as Array<Number>) as Void {
|
||||||
|
var n = ramp.size();
|
||||||
|
var y = inkY(dc, font, cyDesign, CAP_OF_NUM);
|
||||||
|
var ink = capHeight(font, CAP_OF_NUM).toNumber();
|
||||||
|
var top = px(cyDesign) - ink / 2;
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
var y0 = top + (ink * i) / n;
|
||||||
|
var y1 = top + (ink * (i + 1)) / n;
|
||||||
|
if (i == 0) { y0 = 0; }
|
||||||
|
if (i == n - 1) { y1 = height; }
|
||||||
|
if (y1 <= y0) { continue; }
|
||||||
|
dc.setClip(0, y0, width, y1 - y0);
|
||||||
|
dc.setColor(ramp[i], Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.drawText(px(x), y, font, str, just | Graphics.TEXT_JUSTIFY_VCENTER);
|
||||||
|
}
|
||||||
|
dc.clearClip();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设计稿的角度自 12 点起顺时针为正;屏幕 y 轴向下为正,所以是 cy - cos。
|
||||||
|
function polarX(ang as Float, r as Float) as Number {
|
||||||
|
return Math.round(cx + Math.sin(Math.toRadians(ang)) * r).toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
function polarY(ang as Float, r as Float) as Number {
|
||||||
|
return Math.round(cy - Math.cos(Math.toRadians(ang)) * r).toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
function line(dc as Graphics.Dc, x1 as Numeric, y1 as Numeric,
|
||||||
|
x2 as Numeric, y2 as Numeric) as Void {
|
||||||
|
dc.drawLine(px(x1), px(y1), px(x2), px(y2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 线宽在小屏上会被 round 成 0 直接消失,这里兜一个最小值 2。
|
||||||
|
function pen(v as Numeric) as Number {
|
||||||
|
var w = px(v);
|
||||||
|
return w < 2 ? 2 : w;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
source/Owm.mc
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import Toybox.Lang;
|
||||||
|
import Toybox.Application;
|
||||||
|
import Toybox.Math;
|
||||||
|
import Toybox.Time;
|
||||||
|
import Toybox.Time.Gregorian;
|
||||||
|
import Settings;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OpenWeatherMap —— 读取端
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 本模块**不联网**,只负责把后台服务上次存下来的数据读出来。
|
||||||
|
// 真正的网络请求在 Fenix8V3Background.mc 里,由系统按定时器唤醒执行。
|
||||||
|
//
|
||||||
|
// 为什么要拆成两半:表盘进程绝大多数时间根本没在运行,onUpdate 又必须够快,
|
||||||
|
// 不可能在绘制路径里发 HTTP 请求。Connect IQ 给的方案就是后台服务 —— 定时唤醒、
|
||||||
|
// 请求、把结果通过 Background.exit() 交回前台,前台再写进 Application.Storage。
|
||||||
|
//
|
||||||
|
// API key 由用户自己在设置里填(免费档就够)。没填 key 时:
|
||||||
|
// - enabled() 返回 false,后台定时器根本不会注册,一次网络都不会发
|
||||||
|
// - 25 个 OWM 字段全部显示 "--"
|
||||||
|
//
|
||||||
|
// 后台服务会把两个接口的响应**压平成一个扁平字典**再存,这样表盘侧永远不用在
|
||||||
|
// 内存里扛一棵解析好的 JSON 树(手表内存很紧张)。
|
||||||
|
// ============================================================================
|
||||||
|
module Owm {
|
||||||
|
|
||||||
|
const STORE = "owm"; // 在 Application.Storage 里的键名
|
||||||
|
const MAX_AGE = 7200; // 超过两小时的数据视为过期(秒)
|
||||||
|
|
||||||
|
// 用户填的 API key;没填返回空串。
|
||||||
|
function key() as String {
|
||||||
|
return Settings.text("OwmKey");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有填了 key 才算启用。这个判断同时决定了后台定时器要不要注册,
|
||||||
|
// 见 Fenix8V3App.scheduleWeather()。
|
||||||
|
function enabled() as Boolean {
|
||||||
|
return key().length() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取出后台存的整个扁平字典;从没成功拉取过则为 null。
|
||||||
|
function data() as Dictionary? {
|
||||||
|
var d = Application.Storage.getValue(STORE);
|
||||||
|
return (d == null) ? null : (d as Dictionary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取单个字段。键名是后台服务定义的短名("temp"/"feels"/"hi1"…),
|
||||||
|
// 与 Fenix8V3Background.mc 里的 put() 调用一一对应。
|
||||||
|
function get(field as String) {
|
||||||
|
var d = data();
|
||||||
|
if (d == null) { return null; }
|
||||||
|
return d.get(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据是否还新鲜。目前 Fields 里没有强制用它 —— 手表离线一段时间后
|
||||||
|
// 显示稍旧的天气,比直接显示 "--" 更有用;留着给需要的地方调用。
|
||||||
|
function fresh() as Boolean {
|
||||||
|
var t = get("dt");
|
||||||
|
if (t == null) { return false; }
|
||||||
|
return (Time.now().value() - (t as Number)) < MAX_AGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 该字段编号是否属于 OWM 分类。500–537 这一段全是 OWM 的编号,
|
||||||
|
// 与 Garmin 原生天气(300–315)完全分开。
|
||||||
|
function owns(id as Number) as Boolean {
|
||||||
|
return (id >= 500 && id <= 537) || id == 502 || id == 503;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
source/Settings.mc
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import Toybox.Lang;
|
||||||
|
import Toybox.Application;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 设置读取
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 所有存储的偏好设置集中在这里,默认值就是设计稿的那套配置。
|
||||||
|
// 键名与 resources/settings/properties.xml 一一对应。
|
||||||
|
//
|
||||||
|
// 为什么要包一层而不是各处直接调 Properties.getValue:
|
||||||
|
// Connect IQ 的 Properties.getValue 遇到「手表还没存过的键」时**不是返回 null,
|
||||||
|
// 而是抛 Out of Bounds 异常**。每次版本更新新增设置项,在手表把新的
|
||||||
|
// properties.xml 合并进去之前都会撞上这个坑,轻则某个字段读不到,重则整个表盘
|
||||||
|
// 崩掉。所以全部读取都走本模块,异常一律降级成默认值。
|
||||||
|
// ============================================================================
|
||||||
|
module Settings {
|
||||||
|
|
||||||
|
// ---- 四个数据位的默认字段 ----
|
||||||
|
// 字段编号沿用 https://watchface.io/docs/datafields,与 Fields.mc 中一致。
|
||||||
|
// 这四个默认值合起来就是设计稿上的样子。
|
||||||
|
const DEF_LEFT_TOP = 712; // 左上:日出
|
||||||
|
const DEF_RIGHT_TOP = 639; // 右上:预报最高/最低气温
|
||||||
|
const DEF_BOTTOM_LEFT = 3; // 左下:距离
|
||||||
|
const DEF_BOTTOM_RIGHT = 1; // 右下:步数
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// 底层读取:吞掉「键不存在」异常,返回 null 交给上层兜底。
|
||||||
|
// 注意 catch 在这里是有效的(Properties 抛的是普通异常);而 Background
|
||||||
|
// 模块的 Out of Bounds 属于 VM 级错误,catch 不住,那边只能用别的办法绕开,
|
||||||
|
// 详见 Fenix8V3App.scheduleWeather()。
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
function raw(key as String) {
|
||||||
|
try {
|
||||||
|
return Application.Properties.getValue(key);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读数字型设置,取不到就用 def。
|
||||||
|
function number(key as String, def as Number) as Number {
|
||||||
|
var v = raw(key);
|
||||||
|
if (v == null) { return def; }
|
||||||
|
return v as Number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读布尔型设置,取不到就用 def。
|
||||||
|
function bool(key as String, def as Boolean) as Boolean {
|
||||||
|
var v = raw(key);
|
||||||
|
if (v == null) { return def; }
|
||||||
|
return v as Boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读字符串型设置,取不到返回空串(不是 null,省得每个调用点再判一次)。
|
||||||
|
function text(key as String) as String {
|
||||||
|
var v = raw(key);
|
||||||
|
if (v == null) { return ""; }
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// 主题序号(0 基)。做范围钳制是为了防止这种情况:用户存的是「主题 7」,
|
||||||
|
// 之后某个版本删掉了一套主题,存量值就会越界 —— 直接崩数组下标。
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
function themeIndex(count as Number) as Number {
|
||||||
|
var i = number("Theme", 1) - 1;
|
||||||
|
if (i < 0 || i >= count) { return 0; }
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 四个数据位选中的字段编号 ----
|
||||||
|
function leftTop() as Number { return number("LeftTop", DEF_LEFT_TOP); }
|
||||||
|
function rightTop() as Number { return number("RightTop", DEF_RIGHT_TOP); }
|
||||||
|
function bottomLeft() as Number { return number("BottomLeft", DEF_BOTTOM_LEFT); }
|
||||||
|
function bottomRight() as Number { return number("BottomRight", DEF_BOTTOM_RIGHT); }
|
||||||
|
|
||||||
|
// ---- 进度环四段各自绑定的指标 ----
|
||||||
|
// 注意这里的编号是进度环自己的一张短表(0 步数 / 1 卡路里 / 2 距离 /
|
||||||
|
// 3 楼层 / 4 动动条 / 5 活动分钟 / 6 身体电量),与上面数据位的字段编号
|
||||||
|
// 是两套体系,不要混用。取值逻辑在 Fenix8V3View.ringProgress()。
|
||||||
|
function ringTR() as Number { return number("RingTR", 0); } // 右上:步数
|
||||||
|
function ringTL() as Number { return number("RingTL", 1); } // 左上:卡路里
|
||||||
|
function ringBR() as Number { return number("RingBR", 4); } // 右下:动动条
|
||||||
|
function ringBL() as Number { return number("RingBL", 3); } // 左下:楼层
|
||||||
|
|
||||||
|
// 温度单位:0 跟随手表 / 1 强制摄氏 / 2 强制华氏。
|
||||||
|
function tempUnit() as Number { return number("TempUnit", 0); }
|
||||||
|
|
||||||
|
// ---- 三个显隐开关 ----
|
||||||
|
function showTop() as Boolean { return bool("ShowTop", true); }
|
||||||
|
function showBand() as Boolean { return bool("ShowBand", true); }
|
||||||
|
function showBottom() as Boolean { return bool("ShowBottom", true); }
|
||||||
|
}
|
||||||
120
source/Solar.mc
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import Toybox.Lang;
|
||||||
|
import Toybox.Math;
|
||||||
|
import Toybox.Time;
|
||||||
|
import Toybox.Time.Gregorian;
|
||||||
|
import Toybox.Position;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 太阳高度角事件
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Connect IQ 只给了 Weather.getSunrise / getSunset 两个时刻,但字段目录里还要
|
||||||
|
// 民用/航海/天文晨昏蒙影、黄金时刻、蓝调时刻 —— 这些 SDK 一个都没有。
|
||||||
|
//
|
||||||
|
// 好在它们本质是同一个问题换个参数:**太阳中心到达高度角 h 的时刻是几点**。
|
||||||
|
// 所以只写一个 event() 就够了,各事件只是 h 不同:
|
||||||
|
// 日出日落 -0.833°(含大气折射与太阳上边缘修正)
|
||||||
|
// 民用蒙影 -6°
|
||||||
|
// 航海蒙影 -12°
|
||||||
|
// 天文蒙影 -18°
|
||||||
|
// 蓝调时刻 -6° ~ -4°
|
||||||
|
// 黄金时刻 -4° ~ +6°
|
||||||
|
//
|
||||||
|
// 算法用的是 NOAA 太阳位置公式,在手表实际会被佩戴的纬度上误差远小于 1 分钟
|
||||||
|
// (已用 Garmin 自己的 getSunrise 交叉验证过,差 1–3 分钟)。
|
||||||
|
// 极圈内某天太阳压根到不了要求的高度时返回 null,上层显示 "--"。
|
||||||
|
// ============================================================================
|
||||||
|
module Solar {
|
||||||
|
|
||||||
|
// 各事件对应的太阳高度角(度)。
|
||||||
|
const ALT_SUNRISE = -0.833d; // upper limb, refraction included
|
||||||
|
const ALT_CIVIL = -6.0d;
|
||||||
|
const ALT_NAUTICAL = -12.0d;
|
||||||
|
const ALT_ASTRO = -18.0d;
|
||||||
|
const ALT_BLUE = -4.0d; // blue hour runs -6 to -4
|
||||||
|
const ALT_GOLDEN = 6.0d; // golden hour runs -4 to +6
|
||||||
|
|
||||||
|
const RAD = Math.PI / 180.0d;
|
||||||
|
|
||||||
|
// 从 2000-01-01 到当天的**整数天数**,再按经度往东/西挪一点,
|
||||||
|
// 让后面算出的中天时刻落在当地正午而不是 UT 正午。
|
||||||
|
//
|
||||||
|
// ⚠️ 这里必须取整数天。第一版忘了取整,儒略日末尾的 .5 被带了进去,
|
||||||
|
// 结果中天算到了午夜,日出日落直接对调、整体偏了 1.5 天。
|
||||||
|
// 是靠与 Garmin 的 getSunrise 对比才发现的。
|
||||||
|
function daysFromEpoch(g as Gregorian.Info, lonDeg as Double) as Double {
|
||||||
|
var y = g.year;
|
||||||
|
var m = g.month;
|
||||||
|
if (m <= 2) { y = y - 1; m = m + 12; }
|
||||||
|
var a = y / 100;
|
||||||
|
var b = 2 - a + a / 4;
|
||||||
|
var jd = (365.25 * (y + 4716)).toNumber() + (30.6001 * (m + 1)).toNumber()
|
||||||
|
+ g.day + b - 1524.5;
|
||||||
|
var whole = Math.round(jd.toDouble() - 2451545.0d + 0.5d);
|
||||||
|
return whole + 0.0008d - lonDeg / 360.0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 太阳位于高度角 h 时的时角(度)。
|
||||||
|
// acos 的参数超出 [-1,1] 说明这一天太阳始终在该高度之上或之下
|
||||||
|
// (极昼极夜),此时返回 null。
|
||||||
|
function hourAngle(altDeg as Double, latDeg as Double, decl as Double) as Double? {
|
||||||
|
var latR = latDeg * RAD;
|
||||||
|
var c = (Math.sin(altDeg * RAD) - Math.sin(latR) * Math.sin(decl))
|
||||||
|
/ (Math.cos(latR) * Math.cos(decl));
|
||||||
|
if (c > 1.0 || c < -1.0) { return null; }
|
||||||
|
return Math.acos(c) / RAD;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 太阳到达 altDeg 的时刻。morning=true 取东边那次穿越(上午),
|
||||||
|
// false 取西边那次(下午)。
|
||||||
|
function event(altDeg as Double, loc as Position.Location?,
|
||||||
|
morning as Boolean) as Time.Moment? {
|
||||||
|
if (loc == null) { return null; }
|
||||||
|
var deg = loc.toDegrees();
|
||||||
|
var lat = deg[0].toDouble();
|
||||||
|
var lon = deg[1].toDouble();
|
||||||
|
|
||||||
|
var now = Time.now();
|
||||||
|
var g = Gregorian.utcInfo(now, Time.FORMAT_SHORT);
|
||||||
|
var n = daysFromEpoch(g, lon);
|
||||||
|
|
||||||
|
// 平近点角 M、中心差 C、黄经 lambda —— NOAA 公式的三步。
|
||||||
|
var M = (357.5291d + 0.98560028d * n) * RAD;
|
||||||
|
var C = 1.9148d * Math.sin(M) + 0.0200d * Math.sin(2 * M)
|
||||||
|
+ 0.0003d * Math.sin(3 * M);
|
||||||
|
var lambda = (M / RAD + C + 180.0d + 102.9372d) * RAD;
|
||||||
|
// 中天时刻(当地真正午),仍以「距历元的天数」表示。
|
||||||
|
var transit = 2451545.0d + n + 0.0053d * Math.sin(M)
|
||||||
|
- 0.0069d * Math.sin(2 * lambda);
|
||||||
|
var decl = Math.asin(Math.sin(lambda) * Math.sin(23.4397d * RAD));
|
||||||
|
|
||||||
|
var w = hourAngle(altDeg, lat, decl);
|
||||||
|
if (w == null) { return null; }
|
||||||
|
var jd = morning ? (transit - w / 360.0d) : (transit + w / 360.0d);
|
||||||
|
|
||||||
|
// 儒略日换算回 Unix 时间戳。Time.Moment 收的是 UTC 秒数,
|
||||||
|
// 之后 Gregorian.info() 会自动转成本地时间显示。
|
||||||
|
var unix = (jd - 2440587.5d) * 86400.0d;
|
||||||
|
return new Time.Moment(unix.toNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按字段编号返回对应事件的时刻;不属于本模块的编号返回 null。
|
||||||
|
// 蓝调/黄金时刻取的是「该时段开始的那一刻」。
|
||||||
|
function byId(id as Number, loc as Position.Location?) as Time.Moment? {
|
||||||
|
if (id == 710) { return event(ALT_CIVIL, loc, true); }
|
||||||
|
if (id == 709) { return event(ALT_NAUTICAL, loc, true); }
|
||||||
|
if (id == 708) { return event(ALT_ASTRO, loc, true); }
|
||||||
|
if (id == 711) { return event(ALT_CIVIL, loc, true); } // blue hour opens
|
||||||
|
if (id == 714) { return event(ALT_BLUE, loc, true); } // golden hour opens
|
||||||
|
if (id == 715) { return event(ALT_GOLDEN, loc, false); } // golden hour opens
|
||||||
|
if (id == 718) { return event(ALT_BLUE, loc, false); } // blue hour opens
|
||||||
|
if (id == 719) { return event(ALT_CIVIL, loc, false); }
|
||||||
|
if (id == 720) { return event(ALT_NAUTICAL, loc, false); }
|
||||||
|
if (id == 721) { return event(ALT_ASTRO, loc, false); }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function owns(id as Number) as Boolean {
|
||||||
|
return id == 708 || id == 709 || id == 710 || id == 711 || id == 714
|
||||||
|
|| id == 715 || id == 718 || id == 719 || id == 720 || id == 721;
|
||||||
|
}
|
||||||
|
}
|
||||||
222
source/Themes.mc
@@ -1,110 +1,118 @@
|
|||||||
// AUTO-GENERATED from watchface-tokens.json — do not edit by hand.
|
// ⚠️ 本文件由 tools/gen_themes.py 从 design/watchface-*.svg 自动生成,请勿手改。
|
||||||
module Themes {
|
// 要改配色就改设计稿里的 SVG,然后重新跑一遍生成器。
|
||||||
// THEMES: id -> theme dictionary.
|
//
|
||||||
// ringTop / ringBottom are gradient stop arrays: [angle, 0xRRGGBB] sorted ascending.
|
// 七套主题的全部颜色数据。各数组的下标就是主题序号(0=Ember … 6=Kelp)。
|
||||||
var THEMES = {
|
//
|
||||||
"01" => {
|
// RINGTOP/RINGBOTTOM 每根刻度点亮时的颜色,各 21 项。
|
||||||
"name" => "Ember",
|
// 下标 0 是最靠近 12 点 / 6 点的那根。
|
||||||
"ringTop" => [[8,0xFFF203],[15,0xFDDB0D],[25,0xFFC203],[35,0xFFA00A],[45,0xFF8705],[55,0xFF610C],[65,0xFF4904],[75,0xFF2602],[86,0xFF2604]],
|
// 这是从设计稿里**逐根取样**的实测值,不是插值近似。
|
||||||
"ringBottom" => [[7,0xAE1706],[14,0xB32504],[21,0xC03B03],[28,0xCD6206],[35,0xD57805],[42,0xDE9208],[47,0xC98D00]],
|
// HOURS/MINUTES 时间数字的竖向渐变,各 11 个停止点,从上到下。
|
||||||
"tickOff" => 0x545655,
|
// TICKOFF 未点亮刻度的颜色
|
||||||
"anchorDot" => 0x525551,
|
// ANCHORDOT 四个锚点圆点
|
||||||
"bandFill" => 0xFEAA00,
|
// BANDFILL/BANDTEXT 日期带的底色 / 文字色
|
||||||
"bandText" => 0x000002,
|
// ACCENT 图标与底部标签
|
||||||
"accent" => 0xFCA901,
|
// TEXTPRIMARY 各项数值
|
||||||
"textPrimary" => 0xFAFAFA,
|
import Toybox.Lang;
|
||||||
"colon" => 0x525551,
|
|
||||||
"hours" => 0xFBFBFB,
|
|
||||||
"minutes" => 0xFCA901,
|
|
||||||
},
|
|
||||||
"02" => {
|
|
||||||
"name" => "Aurora",
|
|
||||||
"ringTop" => [[8,0xA5FA11],[15,0x9EFA23],[25,0x97F84B],[35,0x8CF965],[45,0x89F87F],[55,0x74FB9F],[65,0x6DFCC6],[75,0x64FCDF],[86,0x57FDFF]],
|
|
||||||
"ringBottom" => [[7,0x55FDFF],[14,0x64FCDF],[21,0x6FFCC6],[28,0x74FBAE],[35,0x7EFA85],[42,0x88F96D],[47,0x81E654]],
|
|
||||||
"tickOff" => 0x545655,
|
|
||||||
"anchorDot" => 0x0F3C82,
|
|
||||||
"bandFill" => 0x525551,
|
|
||||||
"bandText" => 0xFFFFFF,
|
|
||||||
"accent" => 0xABA9AC,
|
|
||||||
"textPrimary" => 0xFAFAFA,
|
|
||||||
"colon" => 0x525551,
|
|
||||||
"hours" => 0xFBFBFB,
|
|
||||||
"minutes" => 0xAAF701,
|
|
||||||
},
|
|
||||||
"03" => {
|
|
||||||
"name" => "Brass",
|
|
||||||
"ringTop" => [[8,0xB4814F],[15,0xB88954],[25,0xC5975C],[35,0xC59E65],[45,0xCBA775],[55,0xD7AE7F],[65,0xDEBC84],[75,0xE7C48D],[86,0xF0D097]],
|
|
||||||
"ringBottom" => [[7,0xB97E4A],[14,0xB68954],[21,0xBD925E],[28,0xC69B67],[35,0xCEA26C],[42,0xD9AB74],[47,0xC59E70]],
|
|
||||||
"tickOff" => 0x3A2815,
|
|
||||||
"anchorDot" => 0x62441E,
|
|
||||||
"bandFill" => 0x392810,
|
|
||||||
"bandText" => 0xFFFFFF,
|
|
||||||
"accent" => 0xB37C4A,
|
|
||||||
"textPrimary" => 0xEBCB93,
|
|
||||||
"colon" => 0x525551,
|
|
||||||
"hours" => 0xFBFBFB,
|
|
||||||
"minutes" => 0xECCC93,
|
|
||||||
},
|
|
||||||
"04" => {
|
|
||||||
"name" => "Voltage",
|
|
||||||
"ringTop" => [[8,0x5CAFF0],[15,0x6EB6E0],[25,0x7FBEC5],[35,0x96CA9D],[45,0xAAD87F],[55,0xBFDF66],[65,0xD6EA40],[75,0xE8F227],[86,0xFFFB0A]],
|
|
||||||
"ringBottom" => [[7,0xF7FA11],[14,0xE9F225],[21,0xD6EB3F],[28,0xC0DE65],[35,0xADD880],[42,0xA0CF98],[47,0x88B98E]],
|
|
||||||
"tickOff" => 0x545655,
|
|
||||||
"anchorDot" => 0x53AAFF,
|
|
||||||
"bandFill" => 0x53AAFF,
|
|
||||||
"bandText" => 0x000000,
|
|
||||||
"accent" => 0xABA9AC,
|
|
||||||
"textPrimary" => 0x51A7FA,
|
|
||||||
"colon" => 0x525551,
|
|
||||||
"hours" => 0xFBFBFB,
|
|
||||||
"minutes" => 0x52A8FD,
|
|
||||||
},
|
|
||||||
"05" => {
|
|
||||||
"name" => "Reef",
|
|
||||||
"ringTop" => [[8,0xF87E0D],[15,0xE78E25],[25,0xCE9B4D],[35,0xBDAC65],[45,0xA5BE88],[55,0x95CA9E],[65,0x7EDEC7],[75,0x6DEBE1],[86,0x57FDFF]],
|
|
||||||
"ringBottom" => [[7,0xFF7905],[14,0xE78B29],[21,0xD59C3E],[28,0xBEAC63],[35,0xADB77F],[42,0xA0C796],[47,0x88B98E]],
|
|
||||||
"tickOff" => 0x545655,
|
|
||||||
"anchorDot" => 0xFE7900,
|
|
||||||
"bandFill" => 0x52FDFF,
|
|
||||||
"bandText" => 0x000000,
|
|
||||||
"accent" => 0xFCA901,
|
|
||||||
"textPrimary" => 0xFAFAFA,
|
|
||||||
"colon" => 0x525551,
|
|
||||||
"hours" => 0xFBFBFB,
|
|
||||||
"minutes" => 0xFC7801,
|
|
||||||
},
|
|
||||||
"06" => {
|
|
||||||
"name" => "Acid",
|
|
||||||
"ringTop" => [[8,0x9CCC14],[15,0xA9CE0C],[25,0xA4D610],[35,0xACDB08],[45,0xB4E107],[55,0xB5E603],[65,0xBFEE04],[75,0xBDF108],[86,0xC7F808]],
|
|
||||||
"ringBottom" => [[7,0xC7FA02],[14,0xBEF20A],[21,0xC0ED04],[28,0xB4E905],[35,0xB4E107],[42,0xADDD0C],[47,0x9BC603]],
|
|
||||||
"tickOff" => 0x545655,
|
|
||||||
"anchorDot" => 0xF58909,
|
|
||||||
"bandFill" => 0x525551,
|
|
||||||
"bandText" => 0x000000,
|
|
||||||
"accent" => 0xC4F801,
|
|
||||||
"textPrimary" => 0xFAFAFA,
|
|
||||||
"colon" => 0x525551,
|
|
||||||
"hours" => 0xDBF731,
|
|
||||||
"minutes" => 0xC4F900,
|
|
||||||
},
|
|
||||||
"07" => {
|
|
||||||
"name" => "Kelp",
|
|
||||||
"ringTop" => [[8,0xEF8917],[15,0xD8901C],[25,0xBD9A34],[35,0xAD9E3C],[45,0x96A54E],[55,0x7DAE5D],[65,0x61B474],[75,0x4EBB84],[86,0x36C294]],
|
|
||||||
"ringBottom" => [[7,0xCE520F],[14,0xB1631D],[21,0xA26A2F],[28,0x9D763E],[35,0x85874B],[42,0x76915D],[47,0x5E8651]],
|
|
||||||
"tickOff" => 0x545655,
|
|
||||||
"anchorDot" => 0xF58909,
|
|
||||||
"bandFill" => 0x525551,
|
|
||||||
"bandText" => 0x000000,
|
|
||||||
"accent" => 0x31C192,
|
|
||||||
"textPrimary" => 0xFAFAFA,
|
|
||||||
"colon" => 0x525551,
|
|
||||||
"hours" => 0xF28708,
|
|
||||||
"minutes" => 0xF4880A,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// theme id list for settings enum (1-based index -> id)
|
module Themes {
|
||||||
var ORDER = [
|
|
||||||
"01", "02", "03", "04", "05", "06", "07"
|
const NAMES = [
|
||||||
|
"Ember",
|
||||||
|
"Aurora",
|
||||||
|
"Brass",
|
||||||
|
"Voltage",
|
||||||
|
"Reef",
|
||||||
|
"Acid",
|
||||||
|
"Kelp",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const RINGTOP = [
|
||||||
|
// 01 Ember
|
||||||
|
[0xFFF200,0xFFE601,0xFFDB01,0xFFCE00,0xFFC200,0xFEB600,0xFEAA00,0xFF9D01,0xFF9100,0xFE8600,0xFE7900,
|
||||||
|
0xFE6E00,0xFF6100,0xFF5500,0xFF4801,0xFF3C01,0xFF3002,0xFF2600,0xFF2600,0xFF2600,0xFF2600],
|
||||||
|
// 02 Aurora
|
||||||
|
[0xA4F909,0xA4F918,0x9DF91E,0x9EFA31,0x95FA39,0x94F94A,0x8AFA52,0x8BFA61,0x84FA6A,0x85FA7C,0x7BFA82,
|
||||||
|
0x7BFA94,0x73FB9B,0x74FBAB,0x6BFBB4,0x6AFCC5,0x63FCCD,0x62FCDE,0x5AFCE6,0x59FDF6,0x52FDFF],
|
||||||
|
// 03 Brass
|
||||||
|
[0xB3814A,0xB5854B,0xB48951,0xBD8E52,0xBD915B,0xC5955B,0xC59962,0xC59D62,0xCD9D6B,0xCCA169,0xCCA573,
|
||||||
|
0xD6AA73,0xD5AE7B,0xD5B37A,0xDEB682,0xDEBA83,0xDEBD8B,0xE5C28B,0xE6C693,0xE6CA94,0xEFCE95],
|
||||||
|
// 04 Voltage
|
||||||
|
[0x5AAEEE,0x62B2E5,0x6AB6DF,0x72BACC,0x7CBEC5,0x83C1B4,0x8CC6AC,0x94C99C,0x9DCE94,0xA3D283,0xACD67A,
|
||||||
|
0xB5DA6A,0xBEDD62,0xC5E252,0xCDE64B,0xD5EA3A,0xDEED30,0xE7F221,0xEDF617,0xF6FA08,0xFFFB01],
|
||||||
|
// 05 Reef
|
||||||
|
[0xF57D07,0xED8618,0xE68D20,0xDE9130,0xD49939,0xCC9D49,0xC5A552,0xBCAA61,0xB3B26A,0xADB67C,0xA4BE83,
|
||||||
|
0x9CC694,0x94CA9C,0x8CD2AB,0x83D6B5,0x7BDEC5,0x74E6CE,0x6AEADF,0x63F2E7,0x5BF6F6,0x52FDFF],
|
||||||
|
// 06 Acid
|
||||||
|
[0x9CCA0B,0x9CCF08,0xA4CE08,0xA4D208,0xA4D60A,0xA4D60A,0xABDA06,0xADDA00,0xADDE02,0xADDE02,0xB4E200,
|
||||||
|
0xB4E600,0xB4E600,0xB4EA00,0xB4EA00,0xBDEE00,0xBDF101,0xBDF101,0xBEF600,0xC6F600,0xC5FA00],
|
||||||
|
// 07 Kelp
|
||||||
|
[0xEE8910,0xDD8D17,0xD49117,0xCD911F,0xC59528,0xBE9931,0xB49D3A,0xAB9D39,0xA3A141,0x94A54A,0x8BA552,
|
||||||
|
0x83AA5A,0x7BAA5A,0x73AE63,0x69B269,0x63B273,0x52B67C,0x4BBA82,0x42B983,0x39BD8B,0x31C293],
|
||||||
|
];
|
||||||
|
|
||||||
|
const RINGBOTTOM = [
|
||||||
|
// 01 Ember
|
||||||
|
[0xAD1602,0xAD1602,0xB21902,0xB32301,0xB43102,0xBC3C01,0xC54801,0xC45501,0xCE6100,0xCC6D00,0xD57900,
|
||||||
|
0xD58401,0xDD9201,0xDE9D02,0xE3A901,0xE7B501,0xEBC202,0xEECE02,0xF2DA02,0xF6E602,0xFAF202],
|
||||||
|
// 02 Aurora
|
||||||
|
[0x52FDFF,0x59FCF6,0x5AFCE6,0x62FCDE,0x62FCCD,0x6AFCC5,0x6BFBB4,0x74FBAB,0x73FB9B,0x7BFA94,0x7BFA83,
|
||||||
|
0x85FA7C,0x84FA6A,0x8BFA61,0x8FFA55,0x93F948,0x97F93C,0x9BF930,0xA0F924,0xA4F918,0xA8F80C],
|
||||||
|
// 03 Brass
|
||||||
|
[0xB47D4A,0xB3814A,0xB4844A,0xB48951,0xBD8E52,0xBD915B,0xC5955B,0xC49962,0xC59962,0xCD9D6A,0xCDA169,
|
||||||
|
0xCCA572,0xD6AA73,0xD5AE7B,0xD8B07D,0xDAB482,0xDDB786,0xE0BB8A,0xE2BF8E,0xE5C293,0xE8C697],
|
||||||
|
// 04 Voltage
|
||||||
|
[0xFFFB01,0xF6FA08,0xEDF617,0xE7F221,0xDEED30,0xD5EA38,0xCDE64B,0xC5E252,0xBEDE62,0xB5DA6A,0xACD67A,
|
||||||
|
0xA3D182,0x9CCE94,0x94C99C,0x8BC5A9,0x83C1B5,0x7BBDC1,0x73B9CD,0x6AB5D9,0x62B1E5,0x5AACF1],
|
||||||
|
// 05 Reef
|
||||||
|
[0xFE7900,0xF57C07,0xED8618,0xE68920,0xDE9130,0xD49939,0xCD9D49,0xC5A552,0xBCAA61,0xB3B26A,0xADB67C,
|
||||||
|
0xA4BE83,0x9CC694,0x94C99C,0x8CD1AA,0x84D7B6,0x7BDEC2,0x73E4CF,0x6BEADB,0x63F1E7,0x5BF7F4],
|
||||||
|
// 06 Acid
|
||||||
|
[0xC5FA00,0xC6F600,0xBEF600,0xBDF101,0xBDF101,0xBDEE00,0xB4EA00,0xB4EA00,0xB4E600,0xB4E600,0xB4E100,
|
||||||
|
0xACDE01,0xADDE02,0xADD900,0xABD801,0xAAD501,0xA9D301,0xA8D002,0xA6CE02,0xA5CB02,0xA4C902],
|
||||||
|
// 07 Kelp
|
||||||
|
[0xCD5008,0xC5550E,0xBD5A1A,0xB46118,0xAC651F,0xA46A29,0x9B712F,0x9C763A,0x94793A,0x8B8241,0x83854A,
|
||||||
|
0x7A8952,0x74915A,0x6A955A,0x649A64,0x5D9F6A,0x55A570,0x4EAA77,0x46AF7D,0x3FB484,0x38BA8A],
|
||||||
|
];
|
||||||
|
|
||||||
|
const HOURS = [
|
||||||
|
// 01 Ember
|
||||||
|
[0xFFFFFF,0xF6F8F5,0xEEF0EE,0xE5E4E6,0xDEDDDE,0xD5D6D5,0xCFCFCF,0xC5C7C4,0xBCBBBD,0xB4B4B4,0xACADAB],
|
||||||
|
// 02 Aurora
|
||||||
|
[0xFFFFFF,0xF2F2F2,0xE3DFE4,0xD1D0D1,0xBEBEBD,0xAEB0AD,0x9E9E9E,0x8E8D8F,0x7F7D80,0x706E71,0x605C61],
|
||||||
|
// 03 Brass
|
||||||
|
[0xFFFFFF,0xF2F2F2,0xE3DFE4,0xD1D0D1,0xBEBEBD,0xAEB0AD,0x9E9E9E,0x8E8D8F,0x7F7D80,0x706E71,0x605C61],
|
||||||
|
// 04 Voltage
|
||||||
|
[0xFFFFFF,0xF2F2F2,0xE3DFE4,0xD1D0D1,0xBEBEBD,0xAEB0AD,0x9E9E9E,0x8E8D8F,0x7F7D80,0x706E71,0x605C61],
|
||||||
|
// 05 Reef
|
||||||
|
[0xFFFFFF,0xF2F2F2,0xE3DFE4,0xD1D0D1,0xBEBEBD,0xAEB0AD,0x9E9E9E,0x8E8D8F,0x7F7D80,0x706E71,0x605C61],
|
||||||
|
// 06 Acid
|
||||||
|
[0xDEFA32,0xDDFA2B,0xD8FA29,0xD5F921,0xD5F91E,0xD2FA17,0xCDFB10,0xCDFB10,0xCDFA08,0xC5FA05,0xC5FA00],
|
||||||
|
// 07 Kelp
|
||||||
|
[0xF58909,0xF68509,0xF18209,0xED8108,0xED8008,0xED7D08,0xEE7A08,0xEE7908,0xE67508,0xE67408,0xE57106],
|
||||||
|
];
|
||||||
|
|
||||||
|
const MINUTES = [
|
||||||
|
// 01 Ember
|
||||||
|
[0xFEAA00,0xFE9E01,0xFF8A00,0xFF7C00,0xFE6A00,0xFE5B01,0xFF4B02,0xFE3B02,0xFF2B01,0xFF2600,0xFF2600],
|
||||||
|
// 02 Aurora
|
||||||
|
[0xACFA00,0x98F914,0x89FA2A,0x79FA44,0x6BFA59,0x5DF971,0x4DFA91,0x3DFAA6,0x28FBC1,0x15FCD9,0x0CFCF5],
|
||||||
|
// 03 Brass
|
||||||
|
[0xEFCE95,0xE6C48F,0xE3BC88,0xDEB581,0xD6AE7B,0xCEA673,0xCD9E6B,0xC49762,0xBD8F5A,0xB88752,0xB3804A],
|
||||||
|
// 04 Voltage
|
||||||
|
[0x53AAFF,0x5FB0E9,0x70B7CF,0x85C0B9,0x96CBA0,0xA4D283,0xB3D86D,0xC4E154,0xD2E93C,0xEAF420,0xFBFB09],
|
||||||
|
// 05 Reef
|
||||||
|
[0xFE7900,0xF18314,0xE3902C,0xD19E44,0xBBAC5E,0xACB675,0x9DC490,0x8CD0A9,0x7EDFC2,0x6FECDA,0x59FAF7],
|
||||||
|
// 06 Acid
|
||||||
|
[0xC5FA00,0xC5FA00,0xC5FA00,0xC5FA00,0xC5FA00,0xC5FA00,0xC5FA00,0xC5FA00,0xC5FA00,0xC5FA00,0xC5FA00],
|
||||||
|
// 07 Kelp
|
||||||
|
[0xF58909,0xF18308,0xED8007,0xEC7808,0xE67508,0xDF6E08,0xDE6907,0xDE6308,0xD45E09,0xD25808,0xCD5408],
|
||||||
|
];
|
||||||
|
|
||||||
|
const TICKOFF = [0x525551, 0x525551, 0x392810, 0x525551, 0x525551, 0x525551, 0x525551];
|
||||||
|
const ANCHORDOT = [0x525551, 0x0F3C82, 0x62441E, 0x53AAFF, 0xFE7900, 0xF58909, 0xF58909];
|
||||||
|
const BANDFILL = [0xFEAA00, 0x525551, 0x392810, 0x53AAFF, 0x52FDFF, 0x525551, 0x525551];
|
||||||
|
const BANDTEXT = [0x000001, 0xFFFFFF, 0xFFFFFE, 0x010000, 0x010001, 0x000000, 0x000000];
|
||||||
|
const COLON = [0x525551, 0x525551, 0x525551, 0x525551, 0x525551, 0x525551, 0x525551];
|
||||||
|
const ACCENT = [0xFEAA05, 0xADABAE, 0xB57E4B, 0xADABAE, 0xFEAA05, 0xC6FA03, 0x34C294];
|
||||||
|
const TEXTPRIMARY = [0xFFFFFF, 0xFFFFFF, 0xF0CF97, 0x55ABFF, 0xFFFFFF, 0xFFFFFF, 0xFFFFFF];
|
||||||
}
|
}
|
||||||
|
|||||||
318
tools/gen_fields.py
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""一张表 → 字段 id 表、两种语言的文案、以及设置项 XML。
|
||||||
|
|
||||||
|
字段编号沿用 https://watchface.io/docs/datafields,方便两边对照;但这里只收录
|
||||||
|
Connect IQ 在手表本机能算出来的字段。
|
||||||
|
|
||||||
|
需要联网服务的(OpenWeatherMap 的部分、StormGlass)、需要手机侧与 Garmin
|
||||||
|
Connect 集成的(按运动分类的周/月累计)、需要用户装了别的 App 的(CGM、
|
||||||
|
补水、行情),都不在这里 —— 具体原因见 README 第 7 节。
|
||||||
|
|
||||||
|
python3 tools/gen_fields.py
|
||||||
|
|
||||||
|
产出:
|
||||||
|
source/FieldTable.mc 字段 id 表 + 下标反查
|
||||||
|
resources/settings/settings.xml 四个数据位的选择器
|
||||||
|
resources/strings/fields.xml 英文的字段名与表盘标签
|
||||||
|
resources-chn/strings/fields.xml 中文的字段名与表盘标签
|
||||||
|
|
||||||
|
加字段的流程:在下面的 FIELDS 表里加一行 → 跑一遍本脚本 →
|
||||||
|
到 source/Fields.mc 对应分类的函数里补一个分支。设置项、两种语言的文案、
|
||||||
|
表盘上的标签都会自动跟上,不会漏。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ROOT = os.path.dirname(HERE)
|
||||||
|
|
||||||
|
# id, key, English name, Chinese name, on-face label (en), on-face label (cn)
|
||||||
|
FIELDS = [
|
||||||
|
(0, "Off", "Off", "关闭", "", ""),
|
||||||
|
|
||||||
|
# ---- date / time ----------------------------------------------------
|
||||||
|
(860, "Time", "Time", "时间", "TIME", "时间"),
|
||||||
|
(880, "UtcTime", "UTC Time", "UTC 时间", "UTC", "UTC"),
|
||||||
|
(864, "AltTime1", "Alternate Time 1", "第二时区 1", "TZ1", "时区1"),
|
||||||
|
(865, "AltTime2", "Alternate Time 2", "第二时区 2", "TZ2", "时区2"),
|
||||||
|
(851, "DayMonthDay", "Day Month Day", "星期 月 日", "DATE", "日期"),
|
||||||
|
(872, "DayDate", "Day Date", "星期 日", "DATE", "日期"),
|
||||||
|
(852, "MonthDay", "Month Day", "月 日", "DATE", "日期"),
|
||||||
|
(855, "DayOfMonth", "Day of Month", "日", "DAY", "日"),
|
||||||
|
(870, "DayShort", "Day (Tue)", "星期简写", "DAY", "星期"),
|
||||||
|
(856, "DayFull", "Full Day (Tuesday)", "星期全称", "DAY", "星期"),
|
||||||
|
(853, "MonthName", "Month (Jan)", "月份", "MON", "月"),
|
||||||
|
(868, "MonthNum", "Month (6)", "月份数字", "MON", "月"),
|
||||||
|
(858, "Meridiem", "AM/PM", "上午/下午", "", ""),
|
||||||
|
(859, "WeekIso", "Week Number (ISO)", "周数 (ISO)", "WEEK", "周"),
|
||||||
|
(850, "Seconds", "Seconds", "秒", "SEC", "秒"),
|
||||||
|
(869, "Seconds0", "Seconds (09)", "秒 (前导零)", "SEC", "秒"),
|
||||||
|
(873, "WeekCal", "Week Number", "周数", "WEEK", "周"),
|
||||||
|
|
||||||
|
# ---- activity -------------------------------------------------------
|
||||||
|
(1, "Steps", "Steps", "步数", "STEP", "步数"),
|
||||||
|
(14, "StepsRound", "Steps Rounded", "步数 (取整)", "STEP", "步数"),
|
||||||
|
(707, "StepsToGo", "Steps to Go", "剩余步数", "TO GO", "还差"),
|
||||||
|
(2, "Calories", "Calories", "卡路里", "KCAL", "千卡"),
|
||||||
|
(603, "ActiveCal", "Active Calories", "活动卡路里", "ACAL", "活动"),
|
||||||
|
(150, "ActiveMin", "Active Minutes", "活动分钟", "MIN", "分钟"),
|
||||||
|
(152, "ActiveMod", "Active Min (Moderate)", "中强度分钟", "MOD", "中强度"),
|
||||||
|
(154, "ActiveVig", "Active Min (Vigorous)", "高强度分钟", "VIG", "高强度"),
|
||||||
|
(151, "ActiveWeek", "Active Minutes (Week)", "本周活动分钟", "WEEK", "周分钟"),
|
||||||
|
(3, "Distance", "Distance", "距离", "DST", "距离"),
|
||||||
|
(4, "FloorsUp", "Floors Climbed", "上楼层数", "FLR", "楼层"),
|
||||||
|
(5, "FloorsDown", "Floors Descended", "下楼层数", "DESC", "下楼"),
|
||||||
|
(6, "MetersUp", "Distance Climbed", "爬升高度", "ASC", "爬升"),
|
||||||
|
(9, "PushDist", "Push Distance", "推行距离", "PUSH", "推行"),
|
||||||
|
(10, "Pushes", "Pushes", "推行次数", "PUSH", "推行"),
|
||||||
|
(219, "StepsWeek", "Steps (Week to Date)", "本周步数", "WEEK", "周步数"),
|
||||||
|
(220, "Steps7d", "Steps (Last 7 Days)", "近 7 天步数", "7D", "7天"),
|
||||||
|
(211, "CalWeek", "Calories (Week to Date)", "本周卡路里", "WEEK", "周千卡"),
|
||||||
|
(212, "Cal7d", "Calories (Last 7 Days)", "近 7 天卡路里", "7D", "7天"),
|
||||||
|
(213, "DistWeek", "Distance (Week to Date)", "本周距离", "WEEK", "周距离"),
|
||||||
|
(214, "Dist7d", "Distance (Last 7 Days)", "近 7 天距离", "7D", "7天"),
|
||||||
|
(203, "FloorsWeek", "Floors (Week to Date)", "本周楼层", "WEEK", "周楼层"),
|
||||||
|
(204, "Floors7d", "Floors (Last 7 Days)", "近 7 天楼层", "7D", "7天"),
|
||||||
|
(200, "ActiveMin7d", "Active Minutes (7 Days)", "近 7 天活动分钟", "7D", "7天"),
|
||||||
|
|
||||||
|
# ---- body -----------------------------------------------------------
|
||||||
|
(618, "HeartRate", "Heart Rate", "心率", "HR", "心率"),
|
||||||
|
(276, "RestingHr", "Resting Heart Rate", "静息心率", "RHR", "静息"),
|
||||||
|
(12, "Stress", "Stress Score", "压力指数", "STRS", "压力"),
|
||||||
|
(635, "PulseOx", "Pulse Ox", "血氧", "SPO2", "血氧"),
|
||||||
|
(11, "Respiration", "Respiration Rate", "呼吸频率", "RESP", "呼吸"),
|
||||||
|
(623, "BodyBattery", "Body Battery", "身体电量", "BODY", "体能"),
|
||||||
|
(8, "MoveBar", "Move Bar", "活动条", "MOVE", "活动"),
|
||||||
|
(15, "MoveBarRev", "Move Bar Reversed", "活动条 (反向)", "MOVE", "活动"),
|
||||||
|
(281, "Weight", "Weight", "体重", "WT", "体重"),
|
||||||
|
(742, "Bmi", "BMI", "BMI", "BMI", "BMI"),
|
||||||
|
(13, "Recovery", "Time to Recovery", "恢复时间", "RCVR", "恢复"),
|
||||||
|
(278, "Vo2Run", "VO2 Max (Run)", "最大摄氧量 (跑)", "VO2", "摄氧"),
|
||||||
|
(277, "Vo2Bike", "VO2 Max (Bike)", "最大摄氧量 (骑)", "VO2", "摄氧"),
|
||||||
|
|
||||||
|
# ---- system ---------------------------------------------------------
|
||||||
|
(250, "Battery", "Battery", "电量", "BATT", "电量"),
|
||||||
|
(251, "BatteryDays", "Battery in Days", "剩余天数", "DAYS", "天数"),
|
||||||
|
(257, "BatteryBoth", "Battery / Days", "电量 / 天数", "BATT", "电量"),
|
||||||
|
(253, "Alarms", "Alarms", "闹钟", "ALRM", "闹钟"),
|
||||||
|
(255, "Notifications", "Notifications", "通知", "MSG", "通知"),
|
||||||
|
(254, "DoNotDisturb", "Do Not Disturb", "勿扰模式", "DND", "勿扰"),
|
||||||
|
(256, "Bluetooth", "Bluetooth", "蓝牙", "BT", "蓝牙"),
|
||||||
|
(252, "Solar", "Solar Intensity", "太阳能强度", "SOLR", "太阳能"),
|
||||||
|
|
||||||
|
# ---- environment ----------------------------------------------------
|
||||||
|
(615, "Altitude", "Altitude", "海拔", "ALT", "海拔"),
|
||||||
|
(616, "SeaPressure", "Sea Level Pressure", "海平面气压", "hPa", "气压"),
|
||||||
|
(733, "AmbPressure", "Ambient Pressure", "环境气压", "hPa", "气压"),
|
||||||
|
(712, "Sunrise", "Sunrise", "日出", "RISE", "日出"),
|
||||||
|
(717, "Sunset", "Sunset", "日落", "SET", "日落"),
|
||||||
|
(700, "NextSun", "Next Sun Event", "下一太阳事件", "SUN", "太阳"),
|
||||||
|
(731, "UntilSun", "Time Until Sun Event", "距太阳事件", "SUN", "太阳"),
|
||||||
|
(706, "MoonAge", "Moon Age", "月龄", "MOON", "月龄"),
|
||||||
|
(732, "MoonLit", "Moon Illumination", "月球照明", "MOON", "月相"),
|
||||||
|
(710, "CivilAm", "Civil Twilight Start", "民用晨光始", "CIVIL", "民用晨"),
|
||||||
|
(709, "NauticalAm", "Nautical Twilight Start", "航海晨光始", "NAUT", "航海晨"),
|
||||||
|
(708, "AstroAm", "Astronomical Twilight Start", "天文晨光始", "ASTR", "天文晨"),
|
||||||
|
(711, "BlueAm", "Blue Hour (AM)", "蓝调时刻 (晨)", "BLUE", "蓝调晨"),
|
||||||
|
(714, "GoldenAm", "Golden Hour (AM)", "黄金时刻 (晨)", "GOLD", "黄金晨"),
|
||||||
|
(715, "GoldenPm", "Golden Hour (PM)", "黄金时刻 (昏)", "GOLD", "黄金昏"),
|
||||||
|
(718, "BluePm", "Blue Hour (PM)", "蓝调时刻 (昏)", "BLUE", "蓝调昏"),
|
||||||
|
(719, "CivilPm", "Civil Twilight End", "民用昏影终", "CIVIL", "民用昏"),
|
||||||
|
(720, "NauticalPm", "Nautical Twilight End", "航海昏影终", "NAUT", "航海昏"),
|
||||||
|
(721, "AstroPm", "Astronomical Twilight End", "天文昏影终", "ASTR", "天文昏"),
|
||||||
|
|
||||||
|
# ---- weather --------------------------------------------------------
|
||||||
|
(306, "Temp", "Temperature", "气温", "TEMP", "气温"),
|
||||||
|
(639, "TempHiLo", "Temperature High/Low", "最高/最低气温", "HI/LO", "高低温"),
|
||||||
|
(300, "FeelsLike", "Feels Like", "体感温度", "FEEL", "体感"),
|
||||||
|
(301, "TempHigh", "High Temperature", "最高气温", "HIGH", "最高"),
|
||||||
|
(302, "TempLow", "Low Temperature", "最低气温", "LOW", "最低"),
|
||||||
|
(305, "Humidity", "Humidity", "湿度", "HUM", "湿度"),
|
||||||
|
(304, "Precip", "Precipitation %", "降水概率", "RAIN", "降水"),
|
||||||
|
(308, "WindSpeed", "Wind Speed", "风速", "WIND", "风速"),
|
||||||
|
(307, "WindBearing", "Wind Bearing", "风向角", "WIND", "风向"),
|
||||||
|
(310, "WindDir", "Wind Direction", "风向", "WIND", "风向"),
|
||||||
|
(309, "WeatherTime", "Weather Update Time", "天气更新时间", "WX", "更新"),
|
||||||
|
(312, "PressureSea", "Pressure (Sea Level)", "海平面气压", "hPa", "气压"),
|
||||||
|
(313, "Uvi", "UV Index", "紫外线指数", "UVI", "紫外线"),
|
||||||
|
(314, "Visibility", "Visibility", "能见度", "VIS", "能见度"),
|
||||||
|
(315, "DewPoint", "Dew Point", "露点", "DEW", "露点"),
|
||||||
|
|
||||||
|
# ---- OpenWeatherMap -------------------------------------------------
|
||||||
|
# Fetched by the background service; see source/Owm.mc.
|
||||||
|
(500, "OwmTemp", "OWM Temperature", "OWM 气温", "TEMP", "气温"),
|
||||||
|
(515, "OwmMinMax", "OWM Max/Min Temp", "OWM 最高/最低", "HI/LO", "高低温"),
|
||||||
|
(503, "OwmFeels", "OWM Feels Like", "OWM 体感温度", "FEEL", "体感"),
|
||||||
|
(504, "OwmMax", "OWM Temp Max", "OWM 最高温", "HIGH", "最高"),
|
||||||
|
(505, "OwmMin", "OWM Temp Min", "OWM 最低温", "LOW", "最低"),
|
||||||
|
(507, "OwmDescShort", "OWM Description", "OWM 天气", "WX", "天气"),
|
||||||
|
(502, "OwmDescLong", "OWM Description (Long)", "OWM 天气详述", "WX", "天气"),
|
||||||
|
(501, "OwmHumidity", "OWM Humidity", "OWM 湿度", "HUM", "湿度"),
|
||||||
|
(506, "OwmPressure", "OWM Pressure", "OWM 气压", "hPa", "气压"),
|
||||||
|
(509, "OwmVisibility", "OWM Visibility", "OWM 能见度", "VIS", "能见度"),
|
||||||
|
(510, "OwmWind", "OWM Wind Speed", "OWM 风速", "WIND", "风速"),
|
||||||
|
(511, "OwmWindDeg", "OWM Wind Degrees", "OWM 风向角", "WIND", "风向"),
|
||||||
|
(518, "OwmWindDir", "OWM Wind Direction", "OWM 风向", "WIND", "风向"),
|
||||||
|
(512, "OwmGust", "OWM Wind Gust", "OWM 阵风", "GUST", "阵风"),
|
||||||
|
(513, "OwmRain", "OWM Rain 1h", "OWM 1h 降雨", "RAIN", "降雨"),
|
||||||
|
(532, "OwmSnow", "OWM Snow 1h", "OWM 1h 降雪", "SNOW", "降雪"),
|
||||||
|
(536, "OwmClouds", "OWM Cloud Cover", "OWM 云量", "CLD", "云量"),
|
||||||
|
(537, "OwmPrecip", "OWM Precipitation %", "OWM 降水概率", "RAIN", "降水"),
|
||||||
|
(530, "OwmDew", "OWM Dew Point", "OWM 露点", "DEW", "露点"),
|
||||||
|
(534, "OwmTomorrow", "OWM Temp Tomorrow", "OWM 明日气温", "TMRW", "明日"),
|
||||||
|
(522, "OwmMinMaxTmrw", "OWM Min/Max Tomorrow", "OWM 明日高低温", "TMRW", "明日"),
|
||||||
|
(535, "OwmDayAfter", "OWM Temp Day After", "OWM 后日气温", "D+2", "后日"),
|
||||||
|
(523, "OwmMinMaxD2", "OWM Min/Max Day After", "OWM 后日高低温", "D+2", "后日"),
|
||||||
|
(516, "OwmCity", "OWM City Name", "OWM 城市", "CITY", "城市"),
|
||||||
|
(517, "OwmUpdated", "OWM Update Time", "OWM 更新时间", "OWM", "更新"),
|
||||||
|
|
||||||
|
# ---- custom ---------------------------------------------------------
|
||||||
|
(703, "Custom1", "Custom Text 1", "自定义文字 1", "", ""),
|
||||||
|
(704, "Custom2", "Custom Text 2", "自定义文字 2", "", ""),
|
||||||
|
(705, "Custom3", "Custom Text 3", "自定义文字 3", "", ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
SLOTS = [("LeftTop", "Top-Left Field", "左上数据"),
|
||||||
|
("RightTop", "Top-Right Field", "右上数据"),
|
||||||
|
("BottomLeft", "Bottom-Left Field", "左下数据"),
|
||||||
|
("BottomRight", "Bottom-Right Field", "右下数据")]
|
||||||
|
|
||||||
|
RING = [(0, "Steps", "Steps", "步数"), (1, "Calories", "Calories", "卡路里"),
|
||||||
|
(2, "Distance", "Distance", "距离"), (3, "Floors", "Floors", "楼层"),
|
||||||
|
(4, "MoveBar", "Move Bar", "动动条"), (5, "ActiveMin", "Active Minutes", "活动分钟"),
|
||||||
|
(6, "BodyBattery", "Body Battery", "身体电量")]
|
||||||
|
|
||||||
|
OTHER_SETTINGS = """
|
||||||
|
<setting propertyKey="@Properties.TempUnit" title="@Strings.SettingTempUnit">
|
||||||
|
<settingConfig type="list">
|
||||||
|
<listEntry value="0">@Strings.TempUnitAuto</listEntry>
|
||||||
|
<listEntry value="1">@Strings.TempUnitC</listEntry>
|
||||||
|
<listEntry value="2">@Strings.TempUnitF</listEntry>
|
||||||
|
</settingConfig>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting propertyKey="@Properties.AltOffset1" title="@Strings.SettingAltOffset1">
|
||||||
|
<settingConfig type="numeric" min="-720" max="840"/>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting propertyKey="@Properties.AltOffset2" title="@Strings.SettingAltOffset2">
|
||||||
|
<settingConfig type="numeric" min="-720" max="840"/>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting propertyKey="@Properties.OwmKey" title="@Strings.SettingOwmKey">
|
||||||
|
<settingConfig type="alphaNumeric" maxLength="40"/>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting propertyKey="@Properties.Custom1" title="@Strings.SettingCustom1">
|
||||||
|
<settingConfig type="alphaNumeric" maxLength="12"/>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting propertyKey="@Properties.Custom2" title="@Strings.SettingCustom2">
|
||||||
|
<settingConfig type="alphaNumeric" maxLength="12"/>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting propertyKey="@Properties.Custom3" title="@Strings.SettingCustom3">
|
||||||
|
<settingConfig type="alphaNumeric" maxLength="12"/>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting propertyKey="@Properties.ShowTop" title="@Strings.SettingShowTop">
|
||||||
|
<settingConfig type="boolean"/>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting propertyKey="@Properties.ShowBand" title="@Strings.SettingShowBand">
|
||||||
|
<settingConfig type="boolean"/>
|
||||||
|
</setting>
|
||||||
|
|
||||||
|
<setting propertyKey="@Properties.ShowBottom" title="@Strings.SettingShowBottom">
|
||||||
|
<settingConfig type="boolean"/>
|
||||||
|
</setting>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def field_list(indent=8):
|
||||||
|
pad = " " * indent
|
||||||
|
return "\n".join('%s<listEntry value="%d">@Strings.Fld%s</listEntry>'
|
||||||
|
% (pad, fid, key) for fid, key, _, _, _, _ in FIELDS)
|
||||||
|
|
||||||
|
|
||||||
|
def write(path, text):
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
open(path, "w").write(text)
|
||||||
|
print("wrote", os.path.relpath(path, ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ids = [f[0] for f in FIELDS]
|
||||||
|
assert len(ids) == len(set(ids)), "duplicate field id"
|
||||||
|
|
||||||
|
# ---- FieldTable.mc ----
|
||||||
|
L = ["// ⚠️ 本文件由 tools/gen_fields.py 自动生成,请勿手改。",
|
||||||
|
"// 要加字段:在生成器的 FIELDS 表里加一行,跑一遍生成器,",
|
||||||
|
"// 再到 source/Fields.mc 对应分类的函数里补一个分支。",
|
||||||
|
"//",
|
||||||
|
"// 字段编号沿用 https://watchface.io/docs/datafields,只收录 Connect IQ",
|
||||||
|
"// 表盘在本机能算出来的那些。",
|
||||||
|
"import Toybox.Lang;", "", "module FieldTable {", "",
|
||||||
|
" // 全部字段 id,顺序与 FieldLabels 资源串(逗号分隔的标签表)严格一致 ——",
|
||||||
|
" // Fields.label() 就是靠这个下标去取对应标签的。",
|
||||||
|
" const IDS = ["]
|
||||||
|
row = " "
|
||||||
|
for i, fid in enumerate(ids):
|
||||||
|
piece = "%d%s" % (fid, "," if i < len(ids) - 1 else "")
|
||||||
|
if len(row) + len(piece) > 92:
|
||||||
|
L.append(row)
|
||||||
|
row = " "
|
||||||
|
row += piece
|
||||||
|
L += [row, " ];", "",
|
||||||
|
" // 由字段 id 反查它在 IDS 里的下标;找不到返回 0(对应「关闭」)。",
|
||||||
|
" function indexOf(id as Number) as Number {",
|
||||||
|
" for (var i = 0; i < IDS.size(); i++) {",
|
||||||
|
" if (IDS[i] == id) { return i; }",
|
||||||
|
" }",
|
||||||
|
" return 0;",
|
||||||
|
" }",
|
||||||
|
"}"]
|
||||||
|
write(os.path.join(ROOT, "source", "FieldTable.mc"), "\n".join(L) + "\n")
|
||||||
|
|
||||||
|
# ---- settings.xml ----
|
||||||
|
S = ['<settings>', '',
|
||||||
|
' <setting propertyKey="@Properties.Theme" title="@Strings.SettingTheme">',
|
||||||
|
' <settingConfig type="list">']
|
||||||
|
names = ["Ember", "Aurora", "Brass", "Voltage", "Reef", "Acid", "Kelp"]
|
||||||
|
for i, n in enumerate(names):
|
||||||
|
S.append(' <listEntry value="%d">@Strings.Theme%s</listEntry>' % (i + 1, n))
|
||||||
|
S += [' </settingConfig>', ' </setting>', '']
|
||||||
|
for slot, _, _ in SLOTS:
|
||||||
|
S += [' <setting propertyKey="@Properties.%s" title="@Strings.Setting%s">' % (slot, slot),
|
||||||
|
' <settingConfig type="list">', field_list(12),
|
||||||
|
' </settingConfig>', ' </setting>', '']
|
||||||
|
for ring in ("RingTR", "RingTL", "RingBR", "RingBL"):
|
||||||
|
S += [' <setting propertyKey="@Properties.%s" title="@Strings.Setting%s">' % (ring, ring),
|
||||||
|
' <settingConfig type="list">']
|
||||||
|
for rid, key, _, _ in RING:
|
||||||
|
S.append(' <listEntry value="%d">@Strings.Metric%s</listEntry>' % (rid, key))
|
||||||
|
S += [' </settingConfig>', ' </setting>', '']
|
||||||
|
S.append(OTHER_SETTINGS.rstrip())
|
||||||
|
S.append('</settings>')
|
||||||
|
write(os.path.join(ROOT, "resources", "settings", "settings.xml"), "\n".join(S) + "\n")
|
||||||
|
|
||||||
|
# ---- field strings, both languages ----
|
||||||
|
for lang, folder, ni, li in (("en", "resources", 2, 4), ("chn", "resources-chn", 3, 5)):
|
||||||
|
X = ['<strings>']
|
||||||
|
X.append(' <!-- Field names shown in the settings picker. -->')
|
||||||
|
for f in FIELDS:
|
||||||
|
X.append(' <string id="Fld%s">%s</string>' % (f[1], f[ni]))
|
||||||
|
X.append('')
|
||||||
|
X.append(' <!-- On-face captions, in FieldTable.IDS order. -->')
|
||||||
|
X.append(' <string id="FieldLabels">%s</string>'
|
||||||
|
% ",".join(f[li] for f in FIELDS))
|
||||||
|
X.append('')
|
||||||
|
for rid, key, en, cn in RING:
|
||||||
|
X.append(' <string id="Metric%s">%s</string>' % (key, en if lang == "en" else cn))
|
||||||
|
X.append('</strings>')
|
||||||
|
write(os.path.join(ROOT, folder, "strings", "fields.xml"), "\n".join(X) + "\n")
|
||||||
|
|
||||||
|
print("%d fields, %d slots" % (len(FIELDS), len(SLOTS)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
202
tools/gen_icons.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""把设计稿的图标路径光栅化成可染色的位图资源。
|
||||||
|
|
||||||
|
design/*.svg 里的日出和天气图标是描边出来的轮廓,最细的特征只有 2–3 个设计
|
||||||
|
像素宽。用圆和线段去重画它们在 454px 上还行,到 280px 就彻底糊掉 —— 那个尺寸
|
||||||
|
下这些细节不足一个物理像素。
|
||||||
|
|
||||||
|
所以改成:把真实路径按每种屏宽各渲染一次,运行时用
|
||||||
|
drawBitmap2(:tintColor) 染上主题色。这样每台设备拿到的都是为它的像素网格
|
||||||
|
专门渲染的图。
|
||||||
|
|
||||||
|
python3 tools/gen_icons.py
|
||||||
|
|
||||||
|
产出 resources-icons-<屏宽>/drawables/{sunrise,sunset,weather}.png 以及配套的
|
||||||
|
drawables.xml;另外产出 resources-launcher-<尺寸>/ 下各机型要求尺寸的启动图标。
|
||||||
|
|
||||||
|
⚠️ 两个要点:
|
||||||
|
1. drawables.xml 里必须写 packingFormat="png"。否则资源编译器会把图片压成
|
||||||
|
调色板格式,而 :tintColor 拒绝调色板化的源,运行时直接抛异常。
|
||||||
|
2. MIP 屏没有 alpha 混合,所以那几个尺寸的覆盖率蒙版会被阈值化成硬边 ——
|
||||||
|
软边在 64 色调色板下反而更脏。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ROOT = os.path.dirname(HERE)
|
||||||
|
SRC = os.path.join(ROOT, "design", "watchface-01-ember.svg")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError:
|
||||||
|
raise SystemExit("this generator needs Pillow: python3 -m pip install pillow")
|
||||||
|
|
||||||
|
# The three icons the comps actually contain, and the design-space centre each
|
||||||
|
# one is drawn around. A common 32x32 design box keeps blitting trivial.
|
||||||
|
BOX = 32.0
|
||||||
|
ICONS = {
|
||||||
|
"sunrise": ("sunrise-icon", 156.32, 89.5, False),
|
||||||
|
"sunset": ("sunrise-icon", 156.32, 89.5, True), # same art, chevron flipped
|
||||||
|
"weather": ("weather-icon", 343.55, 89.5, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
# screen width -> whether the panel can blend alpha (MIP cannot)
|
||||||
|
WIDTHS = {240: False, 260: False, 280: False, 390: True, 416: True, 454: True}
|
||||||
|
|
||||||
|
SS = 8 # supersampling factor
|
||||||
|
|
||||||
|
|
||||||
|
def subpaths(svg, pid):
|
||||||
|
d = re.search(r'<path id="%s" d="([^"]+)"' % pid, svg).group(1)
|
||||||
|
out = []
|
||||||
|
for chunk in d.split("Z"):
|
||||||
|
pts = [(float(x), float(y))
|
||||||
|
for x, y in re.findall(r"(-?\d+\.\d+),(-?\d+\.\d+)", chunk)]
|
||||||
|
if len(pts) >= 3:
|
||||||
|
out.append(pts)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def flip_chevron(paths):
|
||||||
|
"""Mirror the horizon bar so its notch points up -- our sunset variant."""
|
||||||
|
out = []
|
||||||
|
for p in paths:
|
||||||
|
ys = [q[1] for q in p]
|
||||||
|
if min(ys) > 96.0: # the bar is the lowest subpath
|
||||||
|
mid = (min(ys) + max(ys)) / 2.0
|
||||||
|
p = [(x, 2 * mid - y) for x, y in p]
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def scanline_evenodd(paths, size, ox, oy, scale):
|
||||||
|
"""Even-odd fill at supersampled resolution, returned as a coverage mask."""
|
||||||
|
n = size * SS
|
||||||
|
cov = [bytearray(n) for _ in range(n)]
|
||||||
|
edges = []
|
||||||
|
for p in paths:
|
||||||
|
for i in range(len(p)):
|
||||||
|
x0, y0 = p[i]
|
||||||
|
x1, y1 = p[(i + 1) % len(p)]
|
||||||
|
X0 = (x0 - ox) * scale * SS
|
||||||
|
Y0 = (y0 - oy) * scale * SS
|
||||||
|
X1 = (x1 - ox) * scale * SS
|
||||||
|
Y1 = (y1 - oy) * scale * SS
|
||||||
|
if Y0 != Y1:
|
||||||
|
edges.append((Y0, Y1, X0, X1))
|
||||||
|
for row in range(n):
|
||||||
|
yc = row + 0.5
|
||||||
|
xs = []
|
||||||
|
for Y0, Y1, X0, X1 in edges:
|
||||||
|
if (Y0 <= yc < Y1) or (Y1 <= yc < Y0):
|
||||||
|
xs.append(X0 + (yc - Y0) * (X1 - X0) / (Y1 - Y0))
|
||||||
|
if not xs:
|
||||||
|
continue
|
||||||
|
xs.sort()
|
||||||
|
line = cov[row]
|
||||||
|
for i in range(0, len(xs) - 1, 2):
|
||||||
|
a = max(0, int(round(xs[i])))
|
||||||
|
b = min(n, int(round(xs[i + 1])))
|
||||||
|
for x in range(a, b):
|
||||||
|
line[x] = 255
|
||||||
|
return cov
|
||||||
|
|
||||||
|
|
||||||
|
def render(paths, size, cx, cy, blend):
|
||||||
|
scale = size / BOX
|
||||||
|
ox, oy = cx - BOX / 2, cy - BOX / 2
|
||||||
|
cov = scanline_evenodd(paths, size, ox, oy, scale)
|
||||||
|
n = size * SS
|
||||||
|
big = Image.frombytes("L", (n, n), b"".join(bytes(r) for r in cov))
|
||||||
|
small = big.resize((size, size), Image.LANCZOS)
|
||||||
|
if not blend:
|
||||||
|
small = small.point(lambda v: 255 if v >= 110 else 0)
|
||||||
|
white = Image.new("RGB", (size, size), (255, 255, 255))
|
||||||
|
img = white.convert("RGBA")
|
||||||
|
img.putalpha(small)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
# Launcher icon sizes the target devices ask for.
|
||||||
|
LAUNCHER = [40, 56, 60, 65, 70]
|
||||||
|
|
||||||
|
|
||||||
|
def launcher(size):
|
||||||
|
"""A miniature of the face: the gradient ring plus the date band."""
|
||||||
|
from PIL import ImageDraw
|
||||||
|
ss = 8
|
||||||
|
n = size * ss
|
||||||
|
img = Image.new("RGBA", (n, n), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
d.ellipse([0, 0, n - 1, n - 1], fill=(0, 0, 0, 255))
|
||||||
|
svg = open(SRC).read()
|
||||||
|
top = re.findall(r'fill="(#[0-9A-Fa-f]{6})"',
|
||||||
|
re.search(r'<g id="ring-top-right">(.*?)</g>', svg, re.S).group(1))
|
||||||
|
bot = re.findall(r'fill="(#[0-9A-Fa-f]{6})"',
|
||||||
|
re.search(r'<g id="ring-bottom-left">(.*?)</g>', svg, re.S).group(1))
|
||||||
|
ro, ri = n * 0.495, n * 0.375
|
||||||
|
box_o = [n / 2 - ro, n / 2 - ro, n / 2 + ro, n / 2 + ro]
|
||||||
|
# sweep the whole rim, mirroring the comp's two ramps into four quadrants
|
||||||
|
for q, ramp in ((0, top), (1, top), (2, bot), (3, bot)):
|
||||||
|
for i, c in enumerate(ramp):
|
||||||
|
col = tuple(int(c[1 + 2 * j:3 + 2 * j], 16) for j in range(3)) + (255,)
|
||||||
|
span = 90.0 / len(ramp)
|
||||||
|
if q == 0:
|
||||||
|
a0 = -90 + i * span
|
||||||
|
elif q == 1:
|
||||||
|
a0 = -90 - (i + 1) * span
|
||||||
|
elif q == 2:
|
||||||
|
a0 = 90 - (i + 1) * span
|
||||||
|
else:
|
||||||
|
a0 = 90 + i * span
|
||||||
|
d.pieslice(box_o, a0, a0 + span, fill=col)
|
||||||
|
d.ellipse([n / 2 - ri, n / 2 - ri, n / 2 + ri, n / 2 + ri], fill=(0, 0, 0, 255))
|
||||||
|
band = tuple(int("FEAA00"[2 * j:2 * j + 2], 16) for j in range(3)) + (255,)
|
||||||
|
d.rectangle([0, n * 0.44, n, n * 0.60], fill=band)
|
||||||
|
mask = Image.new("L", (n, n), 0)
|
||||||
|
ImageDraw.Draw(mask).ellipse([0, 0, n - 1, n - 1], fill=255)
|
||||||
|
img.putalpha(mask)
|
||||||
|
return img.resize((size, size), Image.LANCZOS)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
svg = open(SRC).read()
|
||||||
|
for size in LAUNCHER:
|
||||||
|
outdir = os.path.join(ROOT, "resources-launcher-%d" % size, "drawables")
|
||||||
|
os.makedirs(outdir, exist_ok=True)
|
||||||
|
launcher(size).save(os.path.join(outdir, "launcher_icon.png"))
|
||||||
|
with open(os.path.join(outdir, "drawables.xml"), "w") as f:
|
||||||
|
f.write('<drawables>\n'
|
||||||
|
' <bitmap id="LauncherIcon" filename="launcher_icon.png"/>\n'
|
||||||
|
'</drawables>\n')
|
||||||
|
print("wrote %s (%dx%d)" % (outdir, size, size))
|
||||||
|
cache = {}
|
||||||
|
for width, blend in sorted(WIDTHS.items()):
|
||||||
|
size = int(round(BOX * width / 500.0))
|
||||||
|
outdir = os.path.join(ROOT, "resources-icons-%d" % width, "drawables")
|
||||||
|
os.makedirs(outdir, exist_ok=True)
|
||||||
|
names = []
|
||||||
|
for name, (pid, cx, cy, flip) in ICONS.items():
|
||||||
|
key = (pid, flip)
|
||||||
|
if key not in cache:
|
||||||
|
p = subpaths(svg, pid)
|
||||||
|
cache[key] = flip_chevron(p) if flip else p
|
||||||
|
img = render(cache[key], size, cx, cy, blend)
|
||||||
|
img.save(os.path.join(outdir, "%s.png" % name))
|
||||||
|
names.append(name)
|
||||||
|
with open(os.path.join(outdir, "drawables.xml"), "w") as f:
|
||||||
|
f.write("<drawables>\n")
|
||||||
|
for n in names:
|
||||||
|
# packingFormat="png" keeps the alpha channel instead of
|
||||||
|
# collapsing the image to a palette; drawBitmap2(:tintColor)
|
||||||
|
# refuses a palettised source.
|
||||||
|
f.write(' <bitmap id="Icon%s" filename="%s.png" '
|
||||||
|
'packingFormat="png"/>\n' % (n.capitalize(), n))
|
||||||
|
f.write("</drawables>\n")
|
||||||
|
print("wrote %s (%dx%d, %s edges)"
|
||||||
|
% (outdir, size, size, "soft" if blend else "hard"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
330
tools/gen_preview_svg.py
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""重放表盘自己的绘制逻辑,产出 preview-themes.svg 和 preview.html。
|
||||||
|
|
||||||
|
颜色直接解析自 source/Themes.mc,几何常量与 source/Fenix8V3View.mc /
|
||||||
|
source/Layout.mc 保持同一套字面值,所以预览不会和固件漂移。
|
||||||
|
|
||||||
|
⚠️ 文字用的是网页字体,真机是 Garmin 内置的 Roboto / Bionic,字形会有差异。
|
||||||
|
**几何和配色可信,字形不可信** —— 判断字形要看模拟器截图。
|
||||||
|
|
||||||
|
python3 tools/gen_preview_svg.py
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ROOT = os.path.dirname(HERE)
|
||||||
|
OUT = os.path.join(ROOT, "preview-themes.svg")
|
||||||
|
OUT_HTML = os.path.join(ROOT, "preview.html")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- geometry --
|
||||||
|
R_IN, R_OUT = 231.09, 249.10
|
||||||
|
TOP_N, TOP_PH, TOP_PITCH, TOP_HW = 21, 6.500, 4.000, 1.554
|
||||||
|
BOT_N, BOT_PH, BOT_PITCH, BOT_HW = 21, 6.005, 3.000, 1.043
|
||||||
|
DOT_RAD, DOT_R = 240.10, 9.00
|
||||||
|
DOT_ANG = [0.0, 93.15, 180.0, 266.85]
|
||||||
|
|
||||||
|
BATT_X, BATT_Y, BATT_W, BATT_H = 192.70, 44.02, 26.20, 17.81
|
||||||
|
BATT_WALL, BATT_TW, BATT_TH = 2.20, 2.53, 9.20
|
||||||
|
BATT_VX, BATT_CY = 232.00, 53.20
|
||||||
|
LEFT_X, RIGHT_X, ICON_CY, VALUE_CY = 157.00, 343.70, 90.00, 131.06
|
||||||
|
TIME_CY, HOURS_R, MINUTES_L = 221.10, 211.40, 276.02
|
||||||
|
COLON_X, COLON_W, COLON_H, COLON_Y1, COLON_Y2 = 237.88, 24.24, 21.30, 183.68, 234.57
|
||||||
|
BAND_Y, BAND_H, BAND_CY = 286.30, 44.19, 308.10
|
||||||
|
BT_CX, BT_CY, BT_HW, BT_HH = 36.88, 306.99, 8.89, 12.92
|
||||||
|
DOW_CX, MD_CX, AMPM_R = 178.05, 298.28, 465.89
|
||||||
|
BOT_VAL_CY, BOT_LAB_CY, DIST_R, STEP_L = 366.76, 410.88, 234.30, 267.40
|
||||||
|
CAP_BAND, CAP_COMP, CAP_TIME = 25.34, 31.50, 98.70
|
||||||
|
|
||||||
|
DOW = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]
|
||||||
|
MON = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN",
|
||||||
|
"JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Themes.mc in --
|
||||||
|
def load_themes():
|
||||||
|
src = open(os.path.join(ROOT, "source", "Themes.mc")).read()
|
||||||
|
|
||||||
|
def const(name):
|
||||||
|
m = re.search(r"const %s = \[(.*?)\n \];" % name, src, re.S)
|
||||||
|
if m: # nested: one row per theme
|
||||||
|
rows, cur = [], []
|
||||||
|
for line in m.group(1).split("\n"):
|
||||||
|
if line.strip().startswith("//"):
|
||||||
|
if cur:
|
||||||
|
rows.append(cur)
|
||||||
|
cur = []
|
||||||
|
continue
|
||||||
|
cur += [int(v, 16) for v in re.findall(r"0x([0-9A-Fa-f]{6})", line)]
|
||||||
|
if cur:
|
||||||
|
rows.append(cur)
|
||||||
|
return rows
|
||||||
|
m = re.search(r"const %s = \[(.*?)\];" % name, src)
|
||||||
|
return [int(v, 16) for v in re.findall(r"0x([0-9A-Fa-f]{6})", m.group(1))]
|
||||||
|
|
||||||
|
names = re.findall(r'^\s+"(\w+)",', src, re.M)
|
||||||
|
return names, {k: const(k) for k in
|
||||||
|
("RINGTOP", "RINGBOTTOM", "HOURS", "MINUTES", "TICKOFF",
|
||||||
|
"ANCHORDOT", "BANDFILL", "BANDTEXT", "COLON",
|
||||||
|
"ACCENT", "TEXTPRIMARY")}
|
||||||
|
|
||||||
|
|
||||||
|
def hx(c):
|
||||||
|
return "#%06X" % (c & 0xFFFFFF)
|
||||||
|
|
||||||
|
|
||||||
|
def polar(ang, r):
|
||||||
|
t = math.radians(ang)
|
||||||
|
return 250.0 + math.sin(t) * r, 250.0 - math.cos(t) * r
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ pieces --
|
||||||
|
def ring(th, T, p):
|
||||||
|
out = []
|
||||||
|
top, bot, off = T["RINGTOP"][th], T["RINGBOTTOM"][th], T["TICKOFF"][th]
|
||||||
|
|
||||||
|
def arc(ramp, lit, ph, pitch, hw, n, base, mirror, from_end):
|
||||||
|
for i in range(n):
|
||||||
|
a = ph + pitch * i
|
||||||
|
on = (i >= n - lit) if from_end else (i < lit)
|
||||||
|
ang = base + (-a if mirror else a)
|
||||||
|
c = [polar(ang - hw, R_OUT), polar(ang + hw, R_OUT),
|
||||||
|
polar(ang + hw, R_IN), polar(ang - hw, R_IN)]
|
||||||
|
out.append('<polygon points="%s" fill="%s"/>' % (
|
||||||
|
" ".join("%.2f,%.2f" % q for q in c), hx(ramp[i] if on else off)))
|
||||||
|
|
||||||
|
arc(top, round(p[0] * TOP_N), TOP_PH, TOP_PITCH, TOP_HW, TOP_N, 0.0, False, True)
|
||||||
|
arc(top, round(p[1] * TOP_N), TOP_PH, TOP_PITCH, TOP_HW, TOP_N, 0.0, True, True)
|
||||||
|
arc(bot, round(p[3] * BOT_N), BOT_PH, BOT_PITCH, BOT_HW, BOT_N, 180.0, False, False)
|
||||||
|
arc(bot, round(p[2] * BOT_N), BOT_PH, BOT_PITCH, BOT_HW, BOT_N, 180.0, True, False)
|
||||||
|
for a in DOT_ANG:
|
||||||
|
x, y = polar(a, DOT_RAD)
|
||||||
|
out.append('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="%s"/>'
|
||||||
|
% (x, y, DOT_R, hx(T["ANCHORDOT"][th])))
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def txt(x, cy, cap, s, anchor, col, weight=700):
|
||||||
|
return ('<text x="%.2f" y="%.2f" font-size="%.2f" font-weight="%d" fill="%s" '
|
||||||
|
'text-anchor="%s" font-family="Roboto Condensed,Arial Narrow,sans-serif" '
|
||||||
|
'dominant-baseline="central">%s</text>'
|
||||||
|
% (x, cy, cap / 0.72, weight, col, anchor, s))
|
||||||
|
|
||||||
|
|
||||||
|
def grad_txt(gid, x, cy, cap, s, anchor, ramp):
|
||||||
|
y0, y1 = cy - cap / 2, cy + cap / 2
|
||||||
|
n = len(ramp)
|
||||||
|
stops = "".join('<stop offset="%.3f" stop-color="%s"/>' % ((i + 0.5) / n, hx(c))
|
||||||
|
for i, c in enumerate(ramp))
|
||||||
|
d = ('<linearGradient id="%s" x1="0" y1="%.2f" x2="0" y2="%.2f" '
|
||||||
|
'gradientUnits="userSpaceOnUse">%s</linearGradient>' % (gid, y0, y1, stops))
|
||||||
|
return d, txt(x, cy, cap, s, anchor, "url(#%s)" % gid)
|
||||||
|
|
||||||
|
|
||||||
|
def battery(x, cy, k, col, level):
|
||||||
|
w, h, wall = BATT_W * k, BATT_H * k, BATT_WALL * k
|
||||||
|
y = cy - h / 2
|
||||||
|
o = ['<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" rx="%.2f" fill="%s"/>'
|
||||||
|
% (x, y, w, h, 3.4 * k, col),
|
||||||
|
'<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>'
|
||||||
|
% (x + w, cy - BATT_TH * k / 2, BATT_TW * k, BATT_TH * k, col),
|
||||||
|
'<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="#000"/>'
|
||||||
|
% (x + wall, y + wall, w - 2 * wall, h - 2 * wall)]
|
||||||
|
if level > 0:
|
||||||
|
o.append('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>'
|
||||||
|
% (x + wall, y + wall, (w - 2 * wall) * level, h - 2 * wall, col))
|
||||||
|
return "".join(o)
|
||||||
|
|
||||||
|
|
||||||
|
def icon_sun(cx, cy, k, col, direction=1):
|
||||||
|
ox, oy = cx - 156.32 * k, cy - 90.34 * k
|
||||||
|
rx, ry = 7.45 * k, 8.93 * k
|
||||||
|
bx, by = ox + 156.39 * k, oy + 94.02 * k
|
||||||
|
pts = [(bx + math.cos(math.radians(180 - 15 * i)) * rx,
|
||||||
|
by - math.sin(math.radians(180 - 15 * i)) * ry) for i in range(13)]
|
||||||
|
o = ['<polygon points="%s" fill="%s"/>'
|
||||||
|
% (" ".join("%.2f,%.2f" % q for q in pts), col)]
|
||||||
|
|
||||||
|
def ln(x1, y1, x2, y2, w):
|
||||||
|
return ('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" '
|
||||||
|
'stroke-width="%.2f" stroke-linecap="round"/>'
|
||||||
|
% (x1, y1, x2, y2, col, w))
|
||||||
|
|
||||||
|
w1 = 2.96 * k
|
||||||
|
o += [ln(ox + 156.39 * k, oy + 79.4 * k, ox + 156.39 * k, oy + 81.1 * k, w1),
|
||||||
|
ln(ox + 148.2 * k, oy + 83.3 * k, ox + 146.5 * k, oy + 84.5 * k, w1),
|
||||||
|
ln(ox + 164.6 * k, oy + 84.5 * k, ox + 166.3 * k, oy + 83.3 * k, w1),
|
||||||
|
ln(ox + 142.6 * k, oy + 92.5 * k, ox + 144.7 * k, oy + 92.5 * k, w1),
|
||||||
|
ln(ox + 168.0 * k, oy + 92.5 * k, ox + 170.1 * k, oy + 92.5 * k, w1)]
|
||||||
|
w2, hy = 2.6 * k, oy + 98.45 * k
|
||||||
|
o += [ln(ox + 142.3 * k, hy, ox + 170.3 * k, hy, w2),
|
||||||
|
ln(ox + 152.6 * k, hy, ox + 156.3 * k, hy + direction * 3.05 * k, w2),
|
||||||
|
ln(ox + 156.3 * k, hy + direction * 3.05 * k, ox + 160.0 * k, hy, w2)]
|
||||||
|
return "".join(o)
|
||||||
|
|
||||||
|
|
||||||
|
def icon_cloud(cx, cy, k, col):
|
||||||
|
ox, oy = cx - 343.55 * k, cy - 89.25 * k
|
||||||
|
|
||||||
|
def ln(x1, y1, x2, y2, w):
|
||||||
|
return ('<line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" stroke="%s" '
|
||||||
|
'stroke-width="%.2f" stroke-linecap="round"/>'
|
||||||
|
% (x1, y1, x2, y2, col, w))
|
||||||
|
|
||||||
|
def body(grow, fill):
|
||||||
|
return ('<circle cx="%.2f" cy="%.2f" r="%.2f" fill="%s"/>'
|
||||||
|
'<circle cx="%.2f" cy="%.2f" r="%.2f" fill="%s"/>'
|
||||||
|
'<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" rx="%.2f" fill="%s"/>'
|
||||||
|
% (ox + 336.6 * k, oy + 94.4 * k, 8.7 * k + grow, fill,
|
||||||
|
ox + 346.4 * k, oy + 96.9 * k, 6.3 * k + grow, fill,
|
||||||
|
ox + 328.0 * k - grow, oy + 95.5 * k - grow,
|
||||||
|
24.6 * k + 2 * grow, 7.75 * k + grow, 3.4 * k, fill))
|
||||||
|
|
||||||
|
o = ['<circle cx="%.2f" cy="%.2f" r="%.2f" fill="%s"/>'
|
||||||
|
% (ox + 347.0 * k, oy + 88.9 * k, 7.8 * k, col)]
|
||||||
|
o += [ln(ox + 346.2 * k, oy + 76.0 * k, ox + 346.2 * k, oy + 78.7 * k, 2.3 * k),
|
||||||
|
ln(ox + 354.9 * k, oy + 87.7 * k, ox + 357.5 * k, oy + 87.7 * k, 2.3 * k),
|
||||||
|
ln(ox + 339.5 * k, oy + 79.5 * k, ox + 338.1 * k, oy + 80.9 * k, 2.7 * k),
|
||||||
|
ln(ox + 353.5 * k, oy + 80.9 * k, ox + 354.6 * k, oy + 79.8 * k, 2.7 * k)]
|
||||||
|
o.append(body(1.7 * k, "#000000"))
|
||||||
|
o.append(body(0.0, col))
|
||||||
|
return "".join(o)
|
||||||
|
|
||||||
|
|
||||||
|
def bluetooth(col):
|
||||||
|
l, r = BT_CX - BT_HW, BT_CX + BT_HW
|
||||||
|
t, b = BT_CY - BT_HH, BT_CY + BT_HH
|
||||||
|
qt, qb = BT_CY - BT_HH / 2, BT_CY + BT_HH / 2
|
||||||
|
pts = [(l, qt), (r, qb), (BT_CX, b), (BT_CX, t), (r, qt), (l, qb)]
|
||||||
|
return ('<polyline points="%s" fill="none" stroke="%s" stroke-width="3.2" '
|
||||||
|
'stroke-linecap="round" stroke-linejoin="round"/>'
|
||||||
|
% (" ".join("%.2f,%.2f" % q for q in pts), col))
|
||||||
|
|
||||||
|
|
||||||
|
def face(th, names, T, data):
|
||||||
|
accent, primary = hx(T["ACCENT"][th]), hx(T["TEXTPRIMARY"][th])
|
||||||
|
defs, s = [], ['<rect width="500" height="500" fill="#000"/>',
|
||||||
|
ring(th, T, data["ring"])]
|
||||||
|
s.append(battery(BATT_X, BATT_CY, 1.0, accent, data["batt"]))
|
||||||
|
s.append(txt(BATT_VX, BATT_CY, CAP_BAND, "%d%%" % round(data["batt"] * 100),
|
||||||
|
"start", primary))
|
||||||
|
s.append(icon_sun(LEFT_X, ICON_CY, 1.0, accent))
|
||||||
|
s.append(txt(LEFT_X, VALUE_CY, CAP_COMP, data["sunrise"], "middle", primary))
|
||||||
|
s.append(icon_cloud(RIGHT_X, ICON_CY, 1.0, accent))
|
||||||
|
s.append(txt(RIGHT_X, VALUE_CY, CAP_COMP, data["hilo"], "middle", primary))
|
||||||
|
|
||||||
|
d, t = grad_txt("gH%d" % th, HOURS_R, TIME_CY, CAP_TIME, data["hh"], "end",
|
||||||
|
T["HOURS"][th])
|
||||||
|
defs.append(d)
|
||||||
|
s.append(t)
|
||||||
|
d, t = grad_txt("gM%d" % th, MINUTES_L, TIME_CY, CAP_TIME, data["mm"], "start",
|
||||||
|
T["MINUTES"][th])
|
||||||
|
defs.append(d)
|
||||||
|
s.append(t)
|
||||||
|
for y in (COLON_Y1, COLON_Y2):
|
||||||
|
s.append('<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>'
|
||||||
|
% (COLON_X, y, COLON_W, COLON_H, hx(T["COLON"][th])))
|
||||||
|
|
||||||
|
band, btxt = hx(T["BANDFILL"][th]), hx(T["BANDTEXT"][th])
|
||||||
|
s.append('<rect x="0" y="%.2f" width="500" height="%.2f" fill="%s"/>'
|
||||||
|
% (BAND_Y, BAND_H, band))
|
||||||
|
s.append(bluetooth(btxt))
|
||||||
|
s.append(txt(DOW_CX, BAND_CY, CAP_BAND, data["dow"], "middle", btxt))
|
||||||
|
s.append(txt(MD_CX, BAND_CY, CAP_BAND, data["md"], "middle", btxt))
|
||||||
|
s.append(txt(AMPM_R, BAND_CY, CAP_BAND, data["ampm"], "end", btxt))
|
||||||
|
|
||||||
|
s.append(txt(DIST_R, BOT_VAL_CY, CAP_COMP, data["dist"], "end", primary))
|
||||||
|
s.append(txt(DIST_R, BOT_LAB_CY, CAP_COMP, "DST", "end", accent))
|
||||||
|
s.append(txt(STEP_L, BOT_VAL_CY, CAP_COMP, data["steps"], "start", primary))
|
||||||
|
s.append(txt(STEP_L, BOT_LAB_CY, CAP_COMP, "STEP", "start", accent))
|
||||||
|
return "".join(defs), "".join(s)
|
||||||
|
|
||||||
|
|
||||||
|
HTML = """<!doctype html>
|
||||||
|
<html lang="zh-CN"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Fenix 8 V3 - 表盘预览</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0a0a0a;--ink:#e9e6e0;--dim:#8d8a84;--line:#26261f}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--ink);
|
||||||
|
font:14px/1.6 -apple-system,"PingFang SC",Helvetica,Arial,sans-serif}
|
||||||
|
.wrap{max-width:760px;margin:0 auto;padding:32px 20px 64px}
|
||||||
|
h1{font-size:20px;margin:0 0 4px}
|
||||||
|
p.lede{color:var(--dim);margin:0 0 24px}
|
||||||
|
.stage{background:#000;border-radius:50%%;width:420px;height:420px;
|
||||||
|
margin:0 auto 24px;overflow:hidden}
|
||||||
|
svg.f{display:none;width:420px;height:420px}
|
||||||
|
svg.f.on{display:block}
|
||||||
|
.themes{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;max-width:420px;margin:0 auto}
|
||||||
|
button{cursor:pointer;border:1px solid var(--line);background:#1c1c19;color:var(--ink);
|
||||||
|
border-radius:8px;padding:9px 4px;font-size:12px}
|
||||||
|
button.on{background:#FEAA00;color:#000;border-color:#FEAA00;font-weight:600}
|
||||||
|
footer{color:var(--dim);font-size:12px;margin-top:28px;text-align:center}
|
||||||
|
</style></head><body><div class="wrap">
|
||||||
|
<h1>Fenix 8 V3 表盘预览</h1>
|
||||||
|
<p class="lede">由 <code>tools/gen_preview_svg.py</code> 从 <code>source/Themes.mc</code>
|
||||||
|
生成,几何常量与 <code>source/Fenix8V3View.mc</code> 一致。文字用网页字体渲染,
|
||||||
|
真机为 Garmin 内置 Roboto / Bionic 字体,字形会有差异。</p>
|
||||||
|
<div class="stage">%(faces)s</div>
|
||||||
|
<div class="themes">%(buttons)s</div>
|
||||||
|
<footer>七套主题 · 进度环状态取自设计稿:右上 21/21、左上 10/21、右下 9/21、左下 14/21</footer>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var faces=document.querySelectorAll("svg.f"), btns=document.querySelectorAll("button");
|
||||||
|
faces[0].classList.add("on");
|
||||||
|
btns.forEach(function(b){b.onclick=function(){
|
||||||
|
var i=+b.dataset.i;
|
||||||
|
faces.forEach(function(f){f.classList.toggle("on",+f.dataset.i===i)});
|
||||||
|
btns.forEach(function(o){o.classList.toggle("on",o===b)});
|
||||||
|
}});
|
||||||
|
</script></body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
names, T = load_themes()
|
||||||
|
# The state the design comps are drawn in, so the preview is comparable.
|
||||||
|
data = {"ring": [1.0, 10 / 21.0, 9 / 21.0, 14 / 21.0],
|
||||||
|
"batt": 0.40, "sunrise": "6:34", "hilo": "63°/52°",
|
||||||
|
"hh": "11", "mm": "22", "dow": "MON", "md": "MAY 26", "ampm": "AM",
|
||||||
|
"dist": "0.7", "steps": "1337"}
|
||||||
|
cell, gap = 230, 24
|
||||||
|
n = len(names)
|
||||||
|
W, H = n * cell + (n + 1) * gap, cell + 70
|
||||||
|
parts = ['<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" '
|
||||||
|
'viewBox="0 0 %d %d">' % (W, H, W, H),
|
||||||
|
'<rect width="%d" height="%d" fill="#0a0a0a"/>' % (W, H)]
|
||||||
|
for i, name in enumerate(names):
|
||||||
|
ox, oy, sc = gap + i * (cell + gap), 24, cell / 500.0
|
||||||
|
defs, body = face(i, names, T, data)
|
||||||
|
parts.append('<defs><clipPath id="c%d"><circle cx="%.1f" cy="%.1f" r="%.1f"/>'
|
||||||
|
'</clipPath>%s</defs>'
|
||||||
|
% (i, ox + cell / 2, oy + cell / 2, cell / 2 - 2, defs))
|
||||||
|
parts.append('<g clip-path="url(#c%d)"><g transform="translate(%d,%d) '
|
||||||
|
'scale(%.4f)">%s</g></g>' % (i, ox, oy, sc, body))
|
||||||
|
parts.append('<text x="%.1f" y="%.1f" fill="#cfccc4" font-size="15" '
|
||||||
|
'font-family="sans-serif" text-anchor="middle">%d · %s</text>'
|
||||||
|
% (ox + cell / 2, oy + cell + 22, i + 1, name))
|
||||||
|
parts.append("</svg>")
|
||||||
|
svg = "\n".join(parts)
|
||||||
|
open(OUT, "w").write(svg)
|
||||||
|
print("wrote %s (%d x %d, %d bytes)" % (OUT, W, H, len(svg)))
|
||||||
|
|
||||||
|
# Same faces again, one per theme, as a switchable page.
|
||||||
|
big = []
|
||||||
|
for i, name in enumerate(names):
|
||||||
|
defs, body = face(i, names, T, data)
|
||||||
|
big.append('<svg class="f" data-i="%d" viewBox="0 0 500 500" '
|
||||||
|
'xmlns="http://www.w3.org/2000/svg"><defs>%s</defs>%s</svg>'
|
||||||
|
% (i, defs, body))
|
||||||
|
btns = "".join('<button data-i="%d"%s>%d · %s</button>'
|
||||||
|
% (i, ' class="on"' if i == 0 else "", i + 1, n)
|
||||||
|
for i, n in enumerate(names))
|
||||||
|
open(OUT_HTML, "w").write(HTML % {"faces": "".join(big), "buttons": btns,
|
||||||
|
"n": len(names)})
|
||||||
|
print("wrote %s (%d themes)" % (OUT_HTML, len(names)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
207
tools/gen_themes.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""从 design/ 的设计稿生成 source/Themes.mc。
|
||||||
|
|
||||||
|
事实来源是七个 watchface-NN-*.svg(原作者用 design/rebuild.py 从位图逆向出来的)。
|
||||||
|
写进 Themes.mc 的每一个颜色都是从这些文件里**量出来的**,没有一个是手挑的。
|
||||||
|
|
||||||
|
python3 tools/gen_themes.py
|
||||||
|
|
||||||
|
每套主题提取出:
|
||||||
|
|
||||||
|
ringTop[21] 顶弧每根刻度点亮时的颜色,下标 0 = 6.5°,20 = 86.5°
|
||||||
|
ringBottom[21] 底弧每根刻度点亮时的颜色,下标 0 = 6.0°,20 = 66.0°
|
||||||
|
tickOff 未点亮刻度的颜色
|
||||||
|
anchorDot 四段弧之间的锚点圆点
|
||||||
|
bandFill/Text 日期带的底色 / 文字色
|
||||||
|
colon 两块冒号
|
||||||
|
hours[11] 小时数字的竖向渐变,从上到下
|
||||||
|
minutes[11] 分钟数字的竖向渐变
|
||||||
|
accent 图标与 DST/STEP 标签
|
||||||
|
textPrimary 各数据位与底部行的数值
|
||||||
|
|
||||||
|
⚠️ 已知近似:设计稿里底弧最多只点亮 14/21 根,所以 ringBottom 的第 15–21 项
|
||||||
|
无法实测,由最后 8 根做最小二乘线性外推得到。只有当指标超过约 67% 时才会
|
||||||
|
显示这几根。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
ROOT = os.path.dirname(HERE)
|
||||||
|
DESIGN = os.path.join(ROOT, "design")
|
||||||
|
OUT = os.path.join(ROOT, "source", "Themes.mc")
|
||||||
|
|
||||||
|
ORDER = ["01", "02", "03", "04", "05", "06", "07"]
|
||||||
|
N_TICK = 21
|
||||||
|
BOT_MEASURED = 14 # lit ticks in the bottom-left arc of every comp
|
||||||
|
FIT_WINDOW = 8 # ticks used for the extrapolation fit
|
||||||
|
|
||||||
|
|
||||||
|
def rgb(h):
|
||||||
|
h = h.lstrip("#")
|
||||||
|
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
||||||
|
|
||||||
|
|
||||||
|
def hexs(c):
|
||||||
|
return "0x%02X%02X%02X" % tuple(max(0, min(255, int(round(v)))) for v in c)
|
||||||
|
|
||||||
|
|
||||||
|
def median(colours):
|
||||||
|
cols = [rgb(c) for c in colours]
|
||||||
|
return tuple(sorted(c[i] for c in cols)[len(cols) // 2] for i in range(3))
|
||||||
|
|
||||||
|
|
||||||
|
def group_colours(svg, gid):
|
||||||
|
m = re.search(r'<g id="%s">(.*?)</g>' % gid, svg, re.S)
|
||||||
|
if m is None:
|
||||||
|
raise SystemExit("missing group %s" % gid)
|
||||||
|
return re.findall(r'fill="(#[0-9A-Fa-f]{6})"', m.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def path_fill(svg, pid):
|
||||||
|
m = re.search(r'<path id="%s"[^>]*fill="(#[0-9A-Fa-f]{6})"' % pid, svg)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def gradient_ramp(svg, gid):
|
||||||
|
"""The 11 usable stops of a digit gradient.
|
||||||
|
|
||||||
|
rebuild.py samples the glyph row by row, so the first and last stop are the
|
||||||
|
antialiased edge rows and read far too dark. Drop them.
|
||||||
|
"""
|
||||||
|
m = re.search(r'<linearGradient id="%s".*?>(.*?)</linearGradient>' % gid, svg, re.S)
|
||||||
|
stops = re.findall(r'stop-color="(#[0-9A-Fa-f]{6})"', m.group(1))
|
||||||
|
return stops[1:-1]
|
||||||
|
|
||||||
|
|
||||||
|
def extrapolate(ramp):
|
||||||
|
"""Extend a measured colour ramp to N_TICK entries by linear fit."""
|
||||||
|
known = [rgb(c) for c in ramp]
|
||||||
|
n = len(known)
|
||||||
|
if n >= N_TICK:
|
||||||
|
return known[:N_TICK]
|
||||||
|
xs = list(range(n - FIT_WINDOW, n))
|
||||||
|
out = list(known)
|
||||||
|
for i in range(n, N_TICK):
|
||||||
|
nxt = []
|
||||||
|
for ch in range(3):
|
||||||
|
ys = [known[x][ch] for x in xs]
|
||||||
|
mx = sum(xs) / float(len(xs))
|
||||||
|
my = sum(ys) / float(len(ys))
|
||||||
|
den = sum((x - mx) ** 2 for x in xs)
|
||||||
|
slope = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / den if den else 0.0
|
||||||
|
nxt.append(max(0, min(255, my + slope * (i - mx))))
|
||||||
|
out.append(tuple(nxt))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def read_theme(tid):
|
||||||
|
hits = [f for f in os.listdir(DESIGN)
|
||||||
|
if f.startswith("watchface-%s-" % tid) and f.endswith(".svg")]
|
||||||
|
if not hits:
|
||||||
|
raise SystemExit("no SVG for theme %s in %s" % (tid, DESIGN))
|
||||||
|
path = os.path.join(DESIGN, hits[0])
|
||||||
|
svg = open(path).read()
|
||||||
|
name = re.search(r"<title>Watch face \d+ (\w+)</title>", svg).group(1)
|
||||||
|
|
||||||
|
top_r = group_colours(svg, "ring-top-right") # fully lit in every comp
|
||||||
|
top_l = group_colours(svg, "ring-top-left") # lit from the far end
|
||||||
|
bot_l = group_colours(svg, "ring-bottom-left") # lit from index 0
|
||||||
|
|
||||||
|
# An unlit tick is whatever colour the dim end of the top-left arc has.
|
||||||
|
tick_off = median(top_l[:8])
|
||||||
|
|
||||||
|
ring_top = [rgb(c) for c in top_r]
|
||||||
|
ring_bottom = extrapolate(bot_l[:BOT_MEASURED])
|
||||||
|
|
||||||
|
band = re.search(r'<g id="date-band">\s*<rect[^>]*fill="(#[0-9A-Fa-f]{6})"', svg).group(1)
|
||||||
|
dot = re.search(r'<g id="ring-anchors">\s*<circle[^>]*fill="(#[0-9A-Fa-f]{6})"', svg).group(1)
|
||||||
|
colon = re.search(r'<g id="time-colon"><rect[^>]*fill="(#[0-9A-Fa-f]{6})"', svg).group(1)
|
||||||
|
|
||||||
|
accent = median([path_fill(svg, p) for p in
|
||||||
|
("battery-icon", "sunrise-icon", "weather-icon",
|
||||||
|
"dist-label", "steps-label")])
|
||||||
|
primary = median([path_fill(svg, p) for p in
|
||||||
|
("battery-value", "sunrise-value", "weather-value",
|
||||||
|
"dist-value", "steps-value")])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": tid,
|
||||||
|
"name": name,
|
||||||
|
"ringTop": ring_top,
|
||||||
|
"ringBottom": ring_bottom,
|
||||||
|
"tickOff": tick_off,
|
||||||
|
"anchorDot": rgb(dot),
|
||||||
|
"bandFill": rgb(band),
|
||||||
|
"bandText": rgb(path_fill(svg, "bt-icon")),
|
||||||
|
"colon": rgb(colon),
|
||||||
|
"hours": [rgb(c) for c in gradient_ramp(svg, "gHours")],
|
||||||
|
"minutes": [rgb(c) for c in gradient_ramp(svg, "gMinutes")],
|
||||||
|
"accent": accent,
|
||||||
|
"textPrimary": primary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_list(colours, indent):
|
||||||
|
pad = " " * indent
|
||||||
|
out, line = [], pad + "["
|
||||||
|
for i, c in enumerate(colours):
|
||||||
|
piece = hexs(c) + ("," if i < len(colours) - 1 else "]")
|
||||||
|
if len(line) + len(piece) > 108:
|
||||||
|
out.append(line)
|
||||||
|
line = pad + " "
|
||||||
|
line += piece
|
||||||
|
out.append(line)
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
themes = [read_theme(t) for t in ORDER]
|
||||||
|
L = []
|
||||||
|
L.append("// ⚠️ 本文件由 tools/gen_themes.py 从 design/watchface-*.svg 自动生成,请勿手改。")
|
||||||
|
L.append("// 要改配色就改设计稿里的 SVG,然后重新跑一遍生成器。")
|
||||||
|
L.append("//")
|
||||||
|
L.append("// 七套主题的全部颜色数据。各数组的下标就是主题序号(0=Ember … 6=Kelp)。")
|
||||||
|
L.append("//")
|
||||||
|
L.append("// RINGTOP/RINGBOTTOM 每根刻度点亮时的颜色,各 21 项。")
|
||||||
|
L.append("// 下标 0 是最靠近 12 点 / 6 点的那根。")
|
||||||
|
L.append("// 这是从设计稿里**逐根取样**的实测值,不是插值近似。")
|
||||||
|
L.append("// HOURS/MINUTES 时间数字的竖向渐变,各 11 个停止点,从上到下。")
|
||||||
|
L.append("// TICKOFF 未点亮刻度的颜色")
|
||||||
|
L.append("// ANCHORDOT 四个锚点圆点")
|
||||||
|
L.append("// BANDFILL/BANDTEXT 日期带的底色 / 文字色")
|
||||||
|
L.append("// ACCENT 图标与底部标签")
|
||||||
|
L.append("// TEXTPRIMARY 各项数值")
|
||||||
|
L.append("import Toybox.Lang;")
|
||||||
|
L.append("")
|
||||||
|
L.append("module Themes {")
|
||||||
|
L.append("")
|
||||||
|
L.append(" const NAMES = [")
|
||||||
|
for t in themes:
|
||||||
|
L.append(' "%s",' % t["name"])
|
||||||
|
L.append(" ];")
|
||||||
|
L.append("")
|
||||||
|
for key in ("ringTop", "ringBottom", "hours", "minutes"):
|
||||||
|
L.append(" const %s = [" % key.upper())
|
||||||
|
for t in themes:
|
||||||
|
L.append(" // %s %s" % (t["id"], t["name"]))
|
||||||
|
L.append(fmt_list(t[key], 8) + ",")
|
||||||
|
L.append(" ];")
|
||||||
|
L.append("")
|
||||||
|
for key in ("tickOff", "anchorDot", "bandFill", "bandText",
|
||||||
|
"colon", "accent", "textPrimary"):
|
||||||
|
vals = ", ".join(hexs(t[key]) for t in themes)
|
||||||
|
L.append(" const %s = [%s];" % (key.upper(), vals))
|
||||||
|
L.append("}")
|
||||||
|
text = "\n".join(L) + "\n"
|
||||||
|
open(OUT, "w").write(text)
|
||||||
|
print("wrote %s (%d themes, %d bytes)" % (OUT, len(themes), len(text)))
|
||||||
|
for t in themes:
|
||||||
|
print(" %s %-8s accent=%s text=%s tickOff=%s" %
|
||||||
|
(t["id"], t["name"], hexs(t["accent"]),
|
||||||
|
hexs(t["textPrimary"]), hexs(t["tickOff"])))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||