Rebuild the face against the design kit; make fields configurable

Vendor the watchface-kit into design/ and derive everything from it instead
of from hand-transcribed constants.

Geometry, from design/rebuild.py and the ink boxes of the traced SVG paths:
  - ring ticks are annular sectors (fillPolygon), not radial lines
  - top arc is 21 ticks at 6.5+4k, not 22 at 2+4k
  - exact radii, tick widths, anchor dots, band and colon rectangles
  - element positions taken from measured ink bounding boxes

Colour, generated by tools/gen_themes.py straight out of the comps:
  - per-tick colour tables (21+21) replace 9/7 interpolated stops
  - 11-stop vertical gradients on the time digits, drawn with clip banding
  - per-element accent / value colours and per-theme unlit tick colour

Data: the design's sunrise and hi/lo fields turn out to be available after
all -- Weather.getSunrise and getDailyForecast both exist in SDK 9.1.0 --
so both are restored, and heart rate is added alongside them.

Bugs found on the way:
  - Gregorian FORMAT_MEDIUM returns day_of_week/month as strings, so the
    date band crashed on every update (caught in the simulator)
  - Stats.battery is already 0-100, multiplying by 100 gave "4000%"
  - moveBarLevel is an integer level 0..5, not a 0..1 ratio, so the move
    bar arc was all-or-nothing

Also: all four field slots are configurable with labels that follow the
selection, temperature unit is a setting, on-face wording moved into
resources with a Simplified Chinese variant, and 24h/distance/temperature
now follow the watch. Fonts are chosen by measured cap height rather than
by ascent.

Verified in the Connect IQ simulator on fenix847mm and by compiling all
17 target devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 22:04:27 +08:00
parent 553c55026a
commit 398018b51e
22 changed files with 3334 additions and 1012 deletions

314
README.md
View File

@@ -1,168 +1,246 @@
# Fenix 8 V3 — Garmin 数据表盘 # Fenix 8 V3 — Garmin 数据表盘
一款基于 Connect IQ 设计稿(`watchface-kit`1:1 还原的多指标数据表盘。设计画布 **500×500** 正方形,渲染时按屏幕宽度等比缩放,支持圆形 AMOLEDFenix 8 / Enduro 3 / Epix 2)与圆屏 MIPFenix 7 / FR965 等)设备。 `watchface-kit` 设计稿 1:1 还原的多指标数据表盘。设计画布 **500×500**,渲染时按屏等比缩放 `s = screenWidth / 500`,支持圆形 AMOLEDFenix 8 / Epix 2 / Venu 3 等)与圆屏 MIPFenix 7 / Enduro 3 / FR255 等)共 17 款设备。
- **SDK**Connect IQ SDK 9.1.0`monkeyc` 9.1.0API 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_preview_svg.py # source/Themes.mc -> preview-themes.svg + preview.html
├── source/
│ ├── Fenix8V3App.mc # 应用入口
│ ├── Fenix8V3View.mc # 全部渲染逻辑
│ └── Themes.mc # ⚠️ 自动生成,勿手改
├── resources/ # 英文文案 + 设置项定义
├── resources-chn/ # 简体中文文案
└── bin/ # 编译产物(已 gitignore
```
| # | 主题 | 主色调 | 分钟数字 | 日期带 | 三份实现(表盘 / SVG 预览 / HTML 预览)只有 **一份颜色数据**`Themes.mc`。预览脚本直接解析它,不会与固件漂移。
|---|------|--------|----------|--------|
| 1 | Ember | 橙红渐变(黄→红) | `0xFCA901` | 橙底黑字 |
| 2 | Aurora | 青绿渐变(黄绿→青) | `0xAAF701` | 深灰底白字 |
| 3 | Brass | 金铜渐变 | `0xECCC93` | 深棕底白字 |
| 4 | Voltage | 蓝→黄渐变 | `0x52A8FD` | 蓝底黑字 |
| 5 | Reef | 橙→青渐变 | `0xFC7801` | 青底黑字 |
| 6 | Acid | 黄绿(近单色) | `0xC4F900` | 深灰底黑字 |
| 7 | Kelp | 橙→绿渐变 | `0xF4880A` | 深灰底黑字 |
--- ---
## 3. 可配置设置Garmin Connect / 表盘设置 ## 2. 布局500 基准,取自设计稿 SVG 路径的墨迹包围盒
通过 `resources/settings/` 暴露,键名与默认值见 `properties.xml` | 元素 | 位置 | 说明 |
|------|------|------|
| 进度环 | 内半径 `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` 的第 1521 项无法实测,由 `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. 可配置设置
共 13 项,通过 Garmin Connect 手机 App 的「表盘设置」修改(定义在 `resources/settings/settings.xml`,默认值在 `properties.xml`)。
| 设置 | 键 | 可选值 | 默认 | | 设置 | 键 | 可选值 | 默认 |
|------|----|--------|------| |------|----|--------|------|
| 主题 | `Theme` | 17Ember…Kelp | 1 | | 主题 | `Theme` | 17 | 1 |
| 顶右弧指标 | `RingTR` | 0 步数 / 1 卡路里 / 2 距离 / 3 楼层 / 4 动动条 | 0 | | 顶右 / 顶左 / 底右 / 底左弧指标 | `RingTR/TL/BR/BL` | 0 步数 / 1 卡路里 / 2 距离 / 3 楼层 / 4 动动条 | 0 / 1 / 4 / 3 |
| 顶左弧指标 | `RingTL` | 同上 | 1 | | 左上数据 | `LeftTop` | 见下 | 6日出 |
| 底右弧指标 | `RingBR` | 同上 | 4 | | 右上数据 | `RightTop` | 见下 | 8最高/最低气温) |
| 底左弧指标 | `RingBL` | 同上 | 3 | | 左下数据 | `BottomLeft` | 见下 | 3(距离) |
| 左上复杂功能 | `LeftTop` | 0 天气 / 1 卡路里 / 2 步数 / 3 距离 / 4 楼层 / 5 电量 | 1 | | 右下数据 | `BottomRight` | 见下 | 2步数 |
| 右上复杂功能 | `RightTop` | 同上 | 0 | | 温度单位 | `TempUnit` | 0 跟随手表 / 1 °C / 2 °F | 0 |
| 显示顶行 | `ShowTop` | 布尔 | true | | 显示顶行 / 日期带 / 底行 | `ShowTop` / `ShowBand` / `ShowBottom` | 布尔 | true |
| 显示日期带 | `ShowBand` | 布尔 | true |
| 显示底行 | `ShowBottom` | 布尔 | true |
> 指标默认值对应设计稿:**顶右=步数、顶左=卡路里、底右=动动条、底左=楼层**;左上=卡路里、右上=天气 **数据项**(四个数据位共用同一张表):`0` 当前气温 · `1` 卡路里 · `2` 步数 · `3` 距离 · `4` 楼层 · `5` 电量 · `6` 日出 · `7` 日落 · `8` 最高/最低气温 · `9` 心率
### 指标进度换算规则(`metricProgress` 默认值即设计稿的配置:左上=日出时间、右上=`63°/52°`、左下=距离、右下=步数。
| idx | 指标 | 进度分母 | 底部两格的小标签跟随所选数据自动变化(`距离` / `步数` / `心率` …),不需要单独配置。
|-----|------|----------|
| 0 | 步数 Steps | `stepGoal`(目标步数) |
| 1 | 卡路里 Calories | 固定 2000 kcal |
| 2 | 距离 Distance | 10 km1,000,000 cm |
| 3 | 楼层 Floors | `floorsClimbedGoal`(默认 10 |
| 4 | 动动条 Move Bar | `moveBarLevel`(归一化 01 |
进度超出 1 会被钳制,弧长即“点亮”刻度数。
--- ---
## 4. 数据源与已知偏差 ## 8. 数据
| 元素 | 数据来源 | 备注 | | 元素 | API |
|------|----------|------| |------|-----|
| 步数 / 卡路里 / 距离 / 楼层 / 动动条 | `ActivityMonitor.getInfo()` | SDK 9.1.0 字段:`steps/stepGoal/calories/distance/floorsClimbed/floorsClimbedGoal/moveBarLevel` | | 步数 / 卡路里 / 距离 / 楼层 / 动动条 | `ActivityMonitor.getInfo()` |
| 天气温度 | `Weather.getCurrentConditions().temperature` | 需手表已配对天气 | | 日出 / 日落 | `Weather.getSunrise/getSunset(location, Time.now())`,位置取 `CurrentConditions.observationLocationPosition`,回退 `Activity.getActivityInfo().currentLocation` |
| 电量 | `System.getSystemStats().battery` | 01 浮点×100 显示 | | 最高 / 最低气温 | `Weather.getDailyForecast()[0].highTemperature / lowTemperature` |
| 日期 / 时间 | `Time.Gregorian` | 12 小时制,带 AM/PM | | 当前气温 | `Weather.getCurrentConditions().temperature` |
| 蓝牙图标 | `System.getDeviceSettings().phoneConnected`(仅取状态) | 见下方偏差 | | 电量 | `System.getSystemStats().battery`0100 的百分比) |
| 时间 / 日期 | `Time.Gregorian``FORMAT_SHORT`(只有短格式的 `day_of_week` / `month` 是数字) |
| 蓝牙 | `DeviceSettings.phoneConnected`,未连接时图标压暗 |
### ⚠️ 与设计稿的关键偏差 单位与制式跟随手表设置:`is24Hour`24 小时制下不显示 AM/PM`distanceUnits`km / mi`temperatureUnits`°C / °F。天气与日出需手表已配对并同步过数据否则显示 `--`
1. **“日出sunrise”元素 → 实时天气温度。** 设计稿顶部复杂功能中有一个日出图标及时间,但 `Weather.CurrentConditions` 在 SDK 9.1.0 **不暴露 sunrise 字段**。因此该位置改为显示**实时天气温度**`NN°`),图标保留太阳造型。
2. **进度环为实时数据**,非设计稿中的静态填充演示值;四段弧指标可在设置中自由分配。
3. **蓝牙图标**目前按日期带文字色常显(未根据 `phoneConnected` 切换亮/暗),如需“未连接变暗”可后续在 `drawDateBand` 中据 `ds.phoneConnected` 调色。
--- ---
## 5. 编译 / 构建 ## 8.5 多语言
需安装 Connect IQ SDK本文基于 9.1.0)并把 `monkeyc` 加入 PATH且准备开发者密钥 `developer_key.der`(已在仓库内) `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 ```bash
# 单设备Enduro 3 主产物) monkeyc -o bin/Fenix8V3.prg -f monkey.jungle -y developer_key.der -d fenix847mm
monkeyc -o bin/Fenix8V3.prg -f monkey.jungle -y developer_key.der -d enduro3 ```
# 导出调试资源(含 -settings.json便于模拟器载入设置 全部 17 款设备批量编译(已验证通过):
monkeyc -o bin/Fenix8V3.prg -f monkey.jungle -y developer_key.der -d enduro3 -x
# 多设备批量(已验证可编译) ```bash
for d in enduro3 fenix843mm fenix847mm fenix8pro47mm fenix8solar47mm \ for d in enduro3 fenix843mm fenix847mm fenix8pro47mm fenix8solar47mm fenix8solar51mm \
fenix8solar51mm epix2 epix2pro47mm epix2pro51mm fenix7 fenix7x \ epix2 epix2pro47mm epix2pro51mm fenix7 fenix7x fenix7s fr965 fr265 fr255 \
fenix7s fr965 fr265 fr255 venu3 vivoactive5; do 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
View 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)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 59 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 59 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 59 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 59 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 59 KiB

View 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"
}
}

View File

@@ -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)

View File

@@ -1,9 +1,7 @@
project.manifest = manifest.xml project.manifest = manifest.xml
base.sourcePath = source base.sourcePath = source
base.resourcePath = resources # resources-chn holds the Simplified Chinese variant of every string;
# the -chn suffix is what tells monkeyc which language it belongs to.
base.resourcePath = resources;resources-chn
base.excludeAnnotations = release base.excludeAnnotations = release
# Uncomment and adjust once you have SDK devices installed, e.g.:
# fenix8.resourcePath = resources;resources-round-amoled
# fenix7.resourcePath = resources;resources-round-mip

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,53 @@
<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="SettingShowTop">显示顶部一行</string>
<string id="SettingShowBand">显示日期带</string>
<string id="SettingShowBottom">显示底部一行</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="MetricSteps">步数</string>
<string id="MetricCalories">卡路里</string>
<string id="MetricDistance">距离</string>
<string id="MetricFloors">楼层</string>
<string id="MetricMoveBar">动动条</string>
<string id="FieldWeather">气温</string>
<string id="FieldHiLo">最高 / 最低气温</string>
<string id="FieldSunrise">日出</string>
<string id="FieldSunset">日落</string>
<string id="FieldCalories">卡路里</string>
<string id="FieldSteps">步数</string>
<string id="FieldDistance">距离</string>
<string id="FieldFloors">楼层</string>
<string id="FieldBattery">电量</string>
<string id="FieldHeartRate">心率</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>
<string id="FieldLabels">气温,卡路里,步数,距离,楼层,电量,日出,日落,高低温,心率</string>
<string id="SettingTempUnit">温度单位</string>
<string id="TempUnitAuto">跟随手表</string>
<string id="TempUnitC">摄氏度 (°C)</string>
<string id="TempUnitF">华氏度 (°F)</string>
</strings>

View File

@@ -4,8 +4,11 @@
<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> <property id="LeftTop" type="number">6</property>
<property id="RightTop" type="number">0</property> <property id="RightTop" type="number">8</property>
<property id="BottomLeft" type="number">3</property>
<property id="BottomRight" type="number">2</property>
<property id="TempUnit" type="number">0</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>

View File

@@ -54,22 +54,68 @@
<setting propertyKey="@Properties.LeftTop" title="@Strings.SettingLeftTop"> <setting propertyKey="@Properties.LeftTop" title="@Strings.SettingLeftTop">
<settingConfig type="list"> <settingConfig type="list">
<listEntry value="0">@Strings.FieldWeather</listEntry> <listEntry value="0">@Strings.FieldWeather</listEntry>
<listEntry value="8">@Strings.FieldHiLo</listEntry>
<listEntry value="6">@Strings.FieldSunrise</listEntry>
<listEntry value="7">@Strings.FieldSunset</listEntry>
<listEntry value="1">@Strings.FieldCalories</listEntry> <listEntry value="1">@Strings.FieldCalories</listEntry>
<listEntry value="2">@Strings.FieldSteps</listEntry> <listEntry value="2">@Strings.FieldSteps</listEntry>
<listEntry value="3">@Strings.FieldDistance</listEntry> <listEntry value="3">@Strings.FieldDistance</listEntry>
<listEntry value="4">@Strings.FieldFloors</listEntry> <listEntry value="4">@Strings.FieldFloors</listEntry>
<listEntry value="5">@Strings.FieldBattery</listEntry> <listEntry value="5">@Strings.FieldBattery</listEntry>
<listEntry value="9">@Strings.FieldHeartRate</listEntry>
</settingConfig> </settingConfig>
</setting> </setting>
<setting propertyKey="@Properties.RightTop" title="@Strings.SettingRightTop"> <setting propertyKey="@Properties.RightTop" title="@Strings.SettingRightTop">
<settingConfig type="list"> <settingConfig type="list">
<listEntry value="0">@Strings.FieldWeather</listEntry> <listEntry value="0">@Strings.FieldWeather</listEntry>
<listEntry value="8">@Strings.FieldHiLo</listEntry>
<listEntry value="6">@Strings.FieldSunrise</listEntry>
<listEntry value="7">@Strings.FieldSunset</listEntry>
<listEntry value="1">@Strings.FieldCalories</listEntry> <listEntry value="1">@Strings.FieldCalories</listEntry>
<listEntry value="2">@Strings.FieldSteps</listEntry> <listEntry value="2">@Strings.FieldSteps</listEntry>
<listEntry value="3">@Strings.FieldDistance</listEntry> <listEntry value="3">@Strings.FieldDistance</listEntry>
<listEntry value="4">@Strings.FieldFloors</listEntry> <listEntry value="4">@Strings.FieldFloors</listEntry>
<listEntry value="5">@Strings.FieldBattery</listEntry> <listEntry value="5">@Strings.FieldBattery</listEntry>
<listEntry value="9">@Strings.FieldHeartRate</listEntry>
</settingConfig>
</setting>
<setting propertyKey="@Properties.BottomLeft" title="@Strings.SettingBottomLeft">
<settingConfig type="list">
<listEntry value="0">@Strings.FieldWeather</listEntry>
<listEntry value="8">@Strings.FieldHiLo</listEntry>
<listEntry value="6">@Strings.FieldSunrise</listEntry>
<listEntry value="7">@Strings.FieldSunset</listEntry>
<listEntry value="1">@Strings.FieldCalories</listEntry>
<listEntry value="2">@Strings.FieldSteps</listEntry>
<listEntry value="3">@Strings.FieldDistance</listEntry>
<listEntry value="4">@Strings.FieldFloors</listEntry>
<listEntry value="5">@Strings.FieldBattery</listEntry>
<listEntry value="9">@Strings.FieldHeartRate</listEntry>
</settingConfig>
</setting>
<setting propertyKey="@Properties.BottomRight" title="@Strings.SettingBottomRight">
<settingConfig type="list">
<listEntry value="0">@Strings.FieldWeather</listEntry>
<listEntry value="8">@Strings.FieldHiLo</listEntry>
<listEntry value="6">@Strings.FieldSunrise</listEntry>
<listEntry value="7">@Strings.FieldSunset</listEntry>
<listEntry value="1">@Strings.FieldCalories</listEntry>
<listEntry value="2">@Strings.FieldSteps</listEntry>
<listEntry value="3">@Strings.FieldDistance</listEntry>
<listEntry value="4">@Strings.FieldFloors</listEntry>
<listEntry value="5">@Strings.FieldBattery</listEntry>
<listEntry value="9">@Strings.FieldHeartRate</listEntry>
</settingConfig>
</setting>
<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> </settingConfig>
</setting> </setting>

View File

@@ -8,6 +8,8 @@
<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 Complication</string>
<string id="SettingRightTop">Top-Right Complication</string> <string id="SettingRightTop">Top-Right Complication</string>
<string id="SettingBottomLeft">Bottom-Left Field</string>
<string id="SettingBottomRight">Bottom-Right Field</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>
@@ -26,10 +28,26 @@
<string id="MetricFloors">Floors</string> <string id="MetricFloors">Floors</string>
<string id="MetricMoveBar">Move Bar</string> <string id="MetricMoveBar">Move Bar</string>
<string id="FieldWeather">Weather</string> <string id="FieldWeather">Temperature</string>
<string id="FieldHiLo">Hi / Lo Temp</string>
<string id="FieldSunrise">Sunrise</string>
<string id="FieldSunset">Sunset</string>
<string id="FieldCalories">Calories</string> <string id="FieldCalories">Calories</string>
<string id="FieldSteps">Steps</string> <string id="FieldSteps">Steps</string>
<string id="FieldDistance">Distance</string> <string id="FieldDistance">Distance</string>
<string id="FieldFloors">Floors</string> <string id="FieldFloors">Floors</string>
<string id="FieldBattery">Battery</string> <string id="FieldBattery">Battery</string>
<string id="FieldHeartRate">Heart Rate</string>
<!-- On-face wording. Comma-separated lists are split at runtime. -->
<string id="DowNames">SUN,MON,TUE,WED,THU,FRI,SAT</string>
<string id="MonNames">JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC</string>
<string id="DateFormat">$1$ $2$</string>
<string id="Meridiem">AM,PM</string>
<string id="FieldLabels">TEMP,KCAL,STEP,DST,FLR,BATT,RISE,SET,HI/LO,HR</string>
<string id="SettingTempUnit">Temperature Unit</string>
<string id="TempUnitAuto">Follow watch</string>
<string id="TempUnitC">Celsius (°C)</string>
<string id="TempUnitF">Fahrenheit (°F)</string>
</strings> </strings>

View File

@@ -3,6 +3,8 @@ import Toybox.Graphics;
import Toybox.Lang; import Toybox.Lang;
import Toybox.Math; import Toybox.Math;
import Toybox.ActivityMonitor; import Toybox.ActivityMonitor;
import Toybox.Activity;
import Toybox.Position;
import Toybox.Weather; import Toybox.Weather;
import Toybox.System; import Toybox.System;
import Toybox.Time; import Toybox.Time;
@@ -10,8 +12,163 @@ import Toybox.Time.Gregorian;
import Toybox.WatchUi; import Toybox.WatchUi;
import Themes; import Themes;
// Every literal below is a coordinate on the 500x500 design canvas, measured
// from design/watchface-*.svg. Nothing here is eyeballed: ring geometry comes
// from design/rebuild.py, element positions are the ink bounding boxes of the
// traced SVG paths. At draw time everything is multiplied by s = width / 500.
class Fenix8V3View extends WatchUi.WatchFace { class Fenix8V3View extends WatchUi.WatchFace {
// ---------------------------------------------------------------- ring
// Ticks are annular sectors of constant angular width, not radial lines.
const R_IN = 231.09;
const R_OUT = 249.10;
const TOP_N = 21;
const TOP_PH = 6.500; // angle of tick 0, degrees clockwise from 12
const TOP_PITCH = 4.000;
const TOP_HW = 1.554; // half of the 3.108 deg tick width
const BOT_N = 21;
const BOT_PH = 6.005; // measured clockwise from 6 o'clock
const BOT_PITCH = 3.000;
const BOT_HW = 1.043;
const DOT_RAD = 240.10; // distance of the anchor dots from centre
const DOT_R = 9.00;
const DOT_ANG = [0.0, 93.15, 180.0, 266.85];
// ------------------------------------------------------------- layout
const BATT_X = 192.70; // battery body, left edge
const BATT_Y = 44.02;
const BATT_W = 26.20; // body only, terminal sits to its right
const BATT_H = 17.81;
const BATT_WALL = 2.20;
const BATT_TW = 2.53; // terminal
const BATT_TH = 9.20;
const BATT_VX = 232.00; // "40%" left edge
const BATT_CY = 53.20;
const LEFT_X = 157.00; // top complications, centred
const RIGHT_X = 343.70;
const ICON_CY = 90.00;
const VALUE_CY = 131.06;
const TIME_CY = 221.10;
const HOURS_R = 211.40; // hour digits, right edge
const MINUTES_L = 276.02; // minute digits, left edge
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; // half width of the bluetooth rune
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; // right edge of both DST lines
const STEP_L = 267.40; // left edge of both STEP lines
// Cap heights of the three text sizes in the design.
const CAP_BAND = 25.34; // date band, battery percentage
const CAP_COMP = 31.50; // complications, bottom row
const CAP_TIME = 98.70; // time digits
// ------------------------------------------------------------- cached
private var mS = 1.0f;
private var mW = 0;
private var mH = 0;
private var mCx = 0;
private var mCy = 0;
private var mFontBand = Graphics.FONT_SMALL;
private var mFontComp = Graphics.FONT_MEDIUM;
private var mFontTime = Graphics.FONT_NUMBER_HOT;
private var mDow = null;
private var mMon = null;
private var mLabels = null;
private var mMeridiem = null;
private var mDateFmt = " ";
function initialize() {
WatchFace.initialize();
}
function onLayout(dc as Dc) as Void {
mW = dc.getWidth();
mH = dc.getHeight();
mS = mW / 500.0;
mCx = mW / 2;
mCy = mH / 2;
var text = [Graphics.FONT_XTINY, Graphics.FONT_TINY, Graphics.FONT_SMALL,
Graphics.FONT_MEDIUM, Graphics.FONT_LARGE];
var nums = [Graphics.FONT_NUMBER_MILD, Graphics.FONT_NUMBER_MEDIUM,
Graphics.FONT_NUMBER_HOT, Graphics.FONT_NUMBER_THAI_HOT];
mFontBand = pickFont(text, CAP_BAND * mS, CAP_OF_TEXT);
mFontComp = pickFont(text, CAP_COMP * mS, CAP_OF_TEXT);
mFontTime = pickFont(nums, CAP_TIME * mS, CAP_OF_NUM);
loadStrings();
}
// Every piece of on-face wording lives in resources so resources-chn can
// replace it. loadResource is not cheap, so pull it all once per layout.
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;
}
// Monkey C has no 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;
}
// Connect IQ exposes ascent and descent but not cap height, and the ascent
// reserves room for diacritics no digit or capital ever uses. These two
// ratios were measured by rendering in the simulator and reading the ink
// back off the screenshot (fenix847mm: xtiny ascent 29 -> caps 22 px,
// numberMild ascent 82 -> digits 58 px).
static const CAP_OF_TEXT = 0.76;
static const CAP_OF_NUM = 0.71;
function capHeight(font as Graphics.FontDefinition, ratio as Float) as Float {
return Graphics.getFontAscent(font) * ratio;
}
// Closest cap height wins -- not the largest that fits, which would leave a
// whole size on the table whenever the design falls between two fonts.
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;
}
function px(v as Numeric) as Number {
return Math.round(v * mS).toNumber();
}
// ----------------------------------------------------------------- utils // ----------------------------------------------------------------- utils
function getSetting(name as String, def as Number) as Number { function getSetting(name as String, def as Number) as Number {
var v = Application.Properties.getValue(name); var v = Application.Properties.getValue(name);
@@ -25,45 +182,58 @@ class Fenix8V3View extends WatchUi.WatchFace {
return v as Boolean; return v as Boolean;
} }
function currentTheme() as Dictionary { function themeIndex() as Number {
var idx = getSetting("Theme", 1); // 1..7 var i = getSetting("Theme", 1) - 1;
var i = idx - 1; if (i < 0 || i >= Themes.NAMES.size()) { i = 0; }
if (i < 0) { i = 0; } return i;
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 { // TEXT_JUSTIFY_VCENTER centres the font box. Caps occupy only the band
var r1 = (c1 & 0xFF0000) >> 16; // from the baseline up by capHeight, so the ink sits high in that box --
var g1 = (c1 & 0x00FF00) >> 8; // push the draw down by the difference to land the ink on cy.
var b1 = (c1 & 0x0000FF); function inkY(font as Graphics.FontDefinition, dc as Dc, cy as Numeric,
var r2 = (c2 & 0xFF0000) >> 16; ratio as Float) as Number {
var g2 = (c2 & 0x00FF00) >> 8; var fh = dc.getFontHeight(font);
var b2 = (c2 & 0x0000FF); var asc = Graphics.getFontAscent(font);
var r = (r1 + ((r2 - r1) * f).toNumber()).toNumber(); return px(cy) + ((fh / 2.0) - asc + capHeight(font, ratio) / 2.0).toNumber();
var g = (g1 + ((g2 - g1) * f).toNumber()).toNumber();
var b = (b1 + ((b2 - b1) * f).toNumber()).toNumber();
return (r << 16) | (g << 8) | b;
} }
// interpolate a gradient stop array [[angle,color],...] (angles ascending) function text(dc as Dc, x as Numeric, cy as Numeric, font as Graphics.FontDefinition,
function lerpStops(stops as Array<Array<Number>>, t as Number) as Number { str as String, just as Number, col as Number) as Void {
var n = stops.size(); dc.setColor(col, Graphics.COLOR_TRANSPARENT);
if (n == 0) { return 0x888888; } dc.drawText(px(x), inkY(font, dc, cy, CAP_OF_TEXT), font, str,
var first = stops[0]; just | Graphics.TEXT_JUSTIFY_VCENTER);
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);
} }
// Draw str once per gradient stop, each pass clipped to its own horizontal
// band -- Monkey C cannot fill glyphs with a gradient directly. The first
// and last band are extended off-glyph so nothing is ever left undrawn.
function gradText(dc as Dc, x as Numeric, cy 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(font, dc, cy, CAP_OF_NUM);
var ink = capHeight(font, CAP_OF_NUM).toNumber();
var top = px(cy) - 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 = mH; }
if (y1 <= y0) { continue; }
dc.setClip(0, y0, mW, y1 - y0);
dc.setColor(ramp[i], Graphics.COLOR_TRANSPARENT);
dc.drawText(px(x), y, font, str, just | Graphics.TEXT_JUSTIFY_VCENTER);
} }
return last[1]; dc.clearClip();
}
// Design angles run clockwise from 12 o'clock.
function polarX(ang as Float, r as Float) as Number {
return Math.round(mCx + Math.sin(Math.toRadians(ang)) * r).toNumber();
}
function polarY(ang as Float, r as Float) as Number {
return Math.round(mCy - Math.cos(Math.toRadians(ang)) * r).toNumber();
} }
// progress 0..1 for a metric index // progress 0..1 for a metric index
@@ -84,276 +254,430 @@ class Fenix8V3View extends WatchUi.WatchFace {
? 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 == 4) { // Move bar: an integer level, not a ratio
if (info.moveBarLevel != null) { p = info.moveBarLevel.toFloat(); } 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 // ------------------------------------------------------------ data bits
function complicationValue(typeIdx as Number, info as ActivityMonitor.Info, function distanceString(info as ActivityMonitor.Info, ds as System.DeviceSettings) as String {
weather as Weather.CurrentConditions, stats as System.Stats) as String { if (info == null || info.distance == null) { return "0.0"; }
if (typeIdx == 0) { // Weather temp var km = info.distance.toFloat() / 100000.0;
if (weather != null && weather.temperature != null) { if (ds != null && ds.distanceUnits == System.UNIT_STATUTE) { km = km * 0.621371; }
return weather.temperature.format("%d") + "\u00B0"; return km.format("%.1f");
} }
return "--";
} else if (typeIdx == 1) { // Calories // TempUnit: 0 follow the watch, 1 force Celsius, 2 force Fahrenheit.
function tempString(c as Numeric?, ds as System.DeviceSettings) as String {
if (c == null) { return "--"; }
var t = c.toFloat();
var mode = getSetting("TempUnit", 0);
var fahrenheit = (mode == 2)
|| (mode == 0 && ds != null && ds.temperatureUnits == System.UNIT_STATUTE);
if (fahrenheit) { t = t * 9.0 / 5.0 + 32.0; }
return t.format("%d") + "°";
}
function clockString(m as Time.Moment?, ds as System.DeviceSettings) as String {
if (m == null) { return "--:--"; }
var g = Gregorian.info(m, Time.FORMAT_SHORT);
var h = g.hour;
if (ds == null || !ds.is24Hour) {
if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; }
}
return h.format("%d") + ":" + g.min.format("%02d");
}
function here(weather as Weather.CurrentConditions?) as Position.Location? {
if (weather != null && weather.observationLocationPosition != null) {
return weather.observationLocationPosition;
}
var act = Activity.getActivityInfo();
if (act != null && act.currentLocation != null) { return act.currentLocation; }
return null;
}
// value string for a complication slot
function complicationValue(typeIdx as Number, info as ActivityMonitor.Info,
weather as Weather.CurrentConditions?, stats as System.Stats,
ds as System.DeviceSettings) as String {
if (typeIdx == 1) { // Calories
return info != null && info.calories != null ? info.calories.format("%d") : "0"; return info != null && info.calories != null ? info.calories.format("%d") : "0";
} else if (typeIdx == 2) { // Steps } else if (typeIdx == 2) { // Steps
return info != null && info.steps != null ? info.steps.format("%d") : "0"; return info != null && info.steps != null ? info.steps.format("%d") : "0";
} else if (typeIdx == 3) { // Distance km } else if (typeIdx == 3) { // Distance
if (info != null && info.distance != null) { return distanceString(info, ds);
return (info.distance.toFloat() / 100000.0).format("%.1f");
}
return "0.0";
} else if (typeIdx == 4) { // Floors } else if (typeIdx == 4) { // Floors
return info != null && info.floorsClimbed != null ? info.floorsClimbed.format("%d") : "0"; return info != null && info.floorsClimbed != null
} else { // Battery ? info.floorsClimbed.format("%d") : "0";
} else if (typeIdx == 5) { // Battery
var pct = 0; var pct = 0;
if (stats != null && stats.battery != null) { pct = (stats.battery * 100.0).toNumber(); } if (stats != null && stats.battery != null) { pct = (stats.battery * 100.0).toNumber(); }
return pct.format("%d") + "%"; return pct.format("%d") + "%";
} else if (typeIdx == 6 || typeIdx == 7) { // Sunrise / sunset
var loc = here(weather);
if (loc == null) { return "--:--"; }
var m = typeIdx == 6 ? Weather.getSunrise(loc, Time.now())
: Weather.getSunset(loc, Time.now());
return clockString(m, ds);
} else if (typeIdx == 9) { // Heart rate
var act = Activity.getActivityInfo();
if (act != null && act.currentHeartRate != null) {
return act.currentHeartRate.format("%d");
} }
var hist = ActivityMonitor.getHeartRateHistory(1, true);
if (hist != null) {
var sample = hist.next();
if (sample != null && sample.heartRate != null
&& sample.heartRate != ActivityMonitor.INVALID_HR_SAMPLE) {
return sample.heartRate.format("%d");
}
}
return "--";
} else if (typeIdx == 8) { // Forecast high / low
var fc = Weather.getDailyForecast();
if (fc != null && fc.size() > 0) {
return tempString(fc[0].highTemperature, ds) + "/"
+ tempString(fc[0].lowTemperature, ds);
}
return "--/--";
}
// 0: current temperature
if (weather != null && weather.temperature != null) {
return tempString(weather.temperature, ds);
}
return "--";
}
// Short caption under a bottom-row value. Indexed like the field types, so
// the label always follows whatever the slot is set to.
function fieldLabel(typeIdx as Number) as String {
if (mLabels == null || typeIdx < 0 || typeIdx >= mLabels.size()) { return ""; }
return mLabels[typeIdx];
} }
// ----------------------------------------------------------------- ring // ----------------------------------------------------------------- ring
function drawTick(dc as Dc, cx as Number, cy as Number, rIn as Number, rOut as Number, function tick(dc as Dc, ang as Float, hw as Float) as Void {
w as Number, ang as Number, col as Number, mirror as Boolean) as Void { var a0 = ang - hw;
var eff = mirror ? -ang : ang; var a1 = ang + hw;
var rad = Math.toRadians(eff); dc.fillPolygon([[polarX(a0, R_OUT * mS), polarY(a0, R_OUT * mS)],
var sin = Math.sin(rad); [polarX(a1, R_OUT * mS), polarY(a1, R_OUT * mS)],
var cos = Math.cos(rad); [polarX(a1, R_IN * mS), polarY(a1, R_IN * mS)],
var ix = (cx + sin * rIn).toNumber(); [polarX(a0, R_IN * mS), polarY(a0, R_IN * mS)]]);
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, // Both top arcs fill from the 9/3 o'clock end towards 12; both bottom arcs
w as Number, angles as Array<Number>, stops as Array<Array<Number>>, // fill outwards from 6 o'clock. That is what the comps show.
off as Number, mirror as Boolean, lit as Number, anchorOuter as Boolean) as Void { function arc(dc as Dc, ramp as Array<Number>, off as Number, lit as Number,
var n = angles.size(); phase as Float, pitch as Float, hw as Float, n as Number,
base as Float, mirror as Boolean, fromEnd as Boolean) as Void {
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 { var top = Themes.RINGTOP[ti] as Array<Number>;
var n = botAngles.size(); var bot = Themes.RINGBOTTOM[ti] as Array<Number>;
for (var i = 0; i < n; i++) { var off = Themes.TICKOFF[ti] as Number;
var a = botAngles[i];
var on = (i < lit); arc(dc, top, off, (pTR * TOP_N).toNumber(), TOP_PH, TOP_PITCH, TOP_HW, TOP_N,
var ang = (180.0 - a).toNumber(); 0.0, false, true);
var col = on ? lerpStops(stops, a) : off; arc(dc, top, off, (pTL * TOP_N).toNumber(), TOP_PH, TOP_PITCH, TOP_HW, TOP_N,
drawTick(dc, cx, cy, rIn, rOut, w, ang, col, mirror); 0.0, true, true);
arc(dc, bot, off, (pBL * BOT_N).toNumber(), BOT_PH, BOT_PITCH, BOT_HW, BOT_N,
180.0, false, false);
arc(dc, bot, off, (pBR * BOT_N).toNumber(), BOT_PH, BOT_PITCH, BOT_HW, BOT_N,
180.0, true, false);
dc.setColor(Themes.ANCHORDOT[ti] as Number, Graphics.COLOR_TRANSPARENT);
var r = px(DOT_R);
for (var i = 0; i < DOT_ANG.size(); i++) {
var a = DOT_ANG[i];
dc.fillCircle(polarX(a, DOT_RAD * mS), polarY(a, DOT_RAD * mS), r);
} }
} }
function drawRing(dc as Dc, cx as Number, cy as Number, s as Float, theme as Dictionary, // ------------------------------------------------------------ icons
pTR as Float, pTL as Float, pBR as Float, pBL as Float) as Void { // The design ships three icons; they are rebuilt here from primitives at
var rOut = (249.5 * s).toNumber(); // the sub-shape positions measured out of the SVG paths.
var rIn = (231.5 * s).toNumber(); function iconBattery(dc as Dc, x as Numeric, cy as Numeric, scale as Float,
var tickOff = theme["tickOff"] as Number; col as Number, level as Float) as Void {
var topStops = theme["ringTop"] as Array<Array<Number>>; var w = BATT_W * scale;
var botStops = theme["ringBottom"] as Array<Array<Number>>; var h = BATT_H * scale;
var topW = (13.0 * s).toNumber(); var wall = BATT_WALL * scale;
var botW = (9.0 * s).toNumber(); var x0 = px(x);
var y0 = px(cy - h / 2);
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 pw = px(w);
var BOT = [6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57,60,63,66]; var ph = px(h);
var r = px(3.4 * scale);
var litTR = (pTR * 22.0).toNumber();
var litTL = (pTL * 22.0).toNumber();
var litBR = (pBR * 21.0).toNumber();
var litBL = (pBL * 21.0).toNumber();
drawArcTop(dc, cx, cy, rIn, rOut, topW, TOP, topStops, tickOff, false, litTR, true);
drawArcTop(dc, cx, cy, rIn, rOut, topW, TOP, topStops, tickOff, true, litTL, true);
drawArcBottom(dc, cx, cy, rIn, rOut, botW, BOT, botStops, tickOff, true, litBL);
drawArcBottom(dc, cx, cy, rIn, rOut, botW, BOT, botStops, tickOff, false, litBR);
// anchor dots
var dotR = (9.5 * s).toNumber();
var dotRad = (240.5 * s).toNumber();
var dotCol = theme["anchorDot"] as Number;
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 rad = Math.toRadians(dotAngles[i]);
var dx = (cx + Math.sin(rad) * dotRad).toNumber();
var dy = (cy - Math.cos(rad) * dotRad).toNumber();
dc.fillCircle(dx, dy, dotR);
}
}
// ------------------------------------------------------------- complications
function drawBattery(dc as Dc, cx as Number, cy as Number, s as Float, theme as Dictionary,
stats as System.Stats) as Void {
var y = (53.5 * s).toNumber();
var pct = 0;
if (stats != null && stats.battery != null) { pct = (stats.battery * 100.0).toNumber(); }
var txt = pct.format("%d") + "%";
var gw = (30.0 * s).toNumber();
var gh = (16.0 * s).toNumber();
var gx = (cx - 52.0 * s).toNumber();
var gy = (y - gh / 2).toNumber();
dc.setPenWidth(2);
dc.setColor(theme["accent"] as Number, Graphics.COLOR_TRANSPARENT);
dc.drawRectangle(gx, gy, gw, gh);
dc.fillRectangle((gx + gw).toNumber(), (gy + gh * 0.28).toNumber(),
(3.0 * s).toNumber(), (gh * 0.44).toNumber());
var fillW = ((gw - 4) * (pct.toFloat() / 100.0)).toNumber();
dc.fillRectangle((gx + 2).toNumber(), (gy + 2).toNumber(), fillW, (gh - 4).toNumber());
dc.setColor(theme["textPrimary"] as Number, Graphics.COLOR_TRANSPARENT);
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);
dc.setColor(col, Graphics.COLOR_TRANSPARENT); dc.setColor(col, Graphics.COLOR_TRANSPARENT);
if (typeIdx == 0) { // sun dc.fillRoundedRectangle(x0, y0, pw, ph, r);
dc.fillCircle(x, y, (r * 0.45).toNumber()); dc.fillRectangle(x0 + pw, px(cy - BATT_TH * scale / 2),
for (var i = 0; i < 8; i++) { px(BATT_TW * scale), px(BATT_TH * scale));
var rad = Math.toRadians(i * 45.0); var ix = x0 + px(wall);
var x1 = (x + Math.sin(rad) * r * 0.6).toNumber(); var iy = y0 + px(wall);
var y1 = (y - Math.cos(rad) * r * 0.6).toNumber(); var iw = pw - 2 * px(wall);
var x2 = (x + Math.sin(rad) * r).toNumber(); var ih = ph - 2 * px(wall);
var y2 = (y - Math.cos(rad) * r).toNumber(); dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT);
dc.drawLine(x1, y1, x2, y2); dc.fillRectangle(ix, iy, iw, ih);
} var fill = (iw * level).toNumber();
} else if (typeIdx == 1) { // flame if (fill > 0) {
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.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.drawLine(cx, (cy - r).toNumber(), cx, (cy + r).toNumber()); dc.fillRectangle(ix, iy, fill, ih);
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, // Filled half-ellipse, flat side down. The comp's dome is 14.90 wide but
// 8.93 tall, so it is not a plain semicircle.
function dome(dc as Dc, cx as Numeric, baseY as Numeric,
rx as Numeric, ry as Numeric) as Void {
var pts = [];
for (var i = 0; i <= 12; i++) {
var rad = Math.toRadians(180.0 - 15.0 * i);
pts.add([Math.round(px(cx) + Math.cos(rad) * px(rx)).toNumber(),
Math.round(px(baseY) - Math.sin(rad) * px(ry)).toNumber()]);
}
dc.fillPolygon(pts);
}
function stub(dc as 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));
}
// Sun over a horizon bar with a chevron. Every ray in the comp is a short
// stub roughly as long as the pen is wide -- drawing them longer is what
// made this icon read taller than the weather one.
// dir = -1 flips the chevron up, which is what the sunset slot uses.
function iconSun(dc as Dc, cx as Numeric, cy as Numeric, scale as Float,
col as Number, dir as Number) as Void {
var k = scale;
var ox = cx - 156.32 * k; // design-space offset of this icon
var oy = cy - 90.34 * k;
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dome(dc, ox + 156.39 * k, oy + 94.02 * k, 7.45 * k, 8.93 * k);
dc.setPenWidth(maxNum(2, px(2.96 * k)));
stub(dc, ox + 156.39 * k, oy + 79.4 * k, ox + 156.39 * k, oy + 81.1 * k);
stub(dc, ox + 148.2 * k, oy + 83.3 * k, ox + 146.5 * k, oy + 84.5 * k);
stub(dc, ox + 164.6 * k, oy + 84.5 * k, ox + 166.3 * k, oy + 83.3 * k);
stub(dc, ox + 142.6 * k, oy + 92.5 * k, ox + 144.7 * k, oy + 92.5 * k);
stub(dc, ox + 168.0 * k, oy + 92.5 * k, ox + 170.1 * k, oy + 92.5 * k);
dc.setPenWidth(maxNum(2, px(2.6 * k)));
var by = oy + 98.45 * k;
stub(dc, ox + 142.3 * k, by, ox + 170.3 * k, by);
stub(dc, ox + 152.6 * k, by, ox + 156.3 * k, by + dir * 3.05 * k);
stub(dc, ox + 156.3 * k, by + dir * 3.05 * k, ox + 160.0 * k, by);
}
// Sun peeking out from behind a cloud. The comp keeps a dark notch where
// the cloud crosses the sun; without it the two shapes merge into one blob
// and the icon reads much heavier than the sunrise one next to it. So the
// cloud is stamped twice: once inflated in black to cut the notch, then at
// true size in the accent colour.
function cloudBody(dc as Dc, ox as Numeric, oy as Numeric, k as Float,
grow as Float) as Void {
dc.fillCircle(px(ox + 336.6 * k), px(oy + 94.4 * k), px(8.7 * k + grow));
dc.fillCircle(px(ox + 346.4 * k), px(oy + 96.9 * k), px(6.3 * k + grow));
var g = px(grow);
dc.fillRoundedRectangle(px(ox + 328.0 * k) - g, px(oy + 95.5 * k) - g,
px(24.6 * k) + 2 * g, px(7.75 * k) + g,
px(3.4 * k));
}
function iconCloud(dc as Dc, cx as Numeric, cy as Numeric, scale as Float,
col as Number) as Void {
var k = scale;
var ox = cx - 343.55 * k;
var oy = cy - 89.25 * k;
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.fillCircle(px(ox + 347.0 * k), px(oy + 88.9 * k), px(7.8 * k));
dc.setPenWidth(maxNum(2, px(2.3 * k)));
stub(dc, ox + 346.2 * k, oy + 76.0 * k, ox + 346.2 * k, oy + 78.7 * k);
stub(dc, ox + 354.9 * k, oy + 87.7 * k, ox + 357.5 * k, oy + 87.7 * k);
dc.setPenWidth(maxNum(2, px(2.7 * k)));
stub(dc, ox + 339.5 * k, oy + 79.5 * k, ox + 338.1 * k, oy + 80.9 * k);
stub(dc, ox + 353.5 * k, oy + 80.9 * k, ox + 354.6 * k, oy + 79.8 * k);
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT);
cloudBody(dc, ox, oy, k, 1.7 * k);
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
cloudBody(dc, ox, oy, k, 0.0);
}
function maxNum(a as Number, b as Number) as Number {
return a > b ? a : b;
}
function drawComplicationIcon(dc as Dc, cx as Numeric, cy as Numeric, scale as Float,
col as Number, typeIdx as Number, level as Float) as Void {
if (typeIdx == 6) {
iconSun(dc, cx, cy, scale, col, 1);
} else if (typeIdx == 7) {
iconSun(dc, cx, cy, scale, col, -1);
} else if (typeIdx == 0 || typeIdx == 8) {
iconCloud(dc, cx, cy, scale, col);
} else if (typeIdx == 5) {
iconBattery(dc, cx - 14.4 * scale, cy, scale, col, level);
} else {
var r = 13.0 * scale;
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(maxNum(2, px(3.0 * scale)));
if (typeIdx == 1) { // calories: flame
dc.fillPolygon([[px(cx), px(cy - r)],
[px(cx + r * 0.72), px(cy + r * 0.25)],
[px(cx + r * 0.40), px(cy + r * 0.85)],
[px(cx - r * 0.40), px(cy + r * 0.85)],
[px(cx - r * 0.72), px(cy + r * 0.25)]]);
} else if (typeIdx == 2) { // steps: two prints
dc.fillRoundedRectangle(px(cx - r * 0.85), px(cy - r * 0.75),
px(r * 0.62), px(r * 1.15), px(r * 0.3));
dc.fillRoundedRectangle(px(cx + r * 0.22), px(cy - r * 0.35),
px(r * 0.62), px(r * 1.15), px(r * 0.3));
} else if (typeIdx == 3) { // distance: arrow
dc.fillPolygon([[px(cx), px(cy - r)],
[px(cx + r * 0.75), px(cy + r * 0.8)],
[px(cx), px(cy + r * 0.35)],
[px(cx - r * 0.75), px(cy + r * 0.8)]]);
} else if (typeIdx == 9) { // heart rate
dc.fillCircle(px(cx - r * 0.38), px(cy - r * 0.28), px(r * 0.44));
dc.fillCircle(px(cx + r * 0.38), px(cy - r * 0.28), px(r * 0.44));
dc.fillPolygon([[px(cx - r * 0.78), px(cy - r * 0.12)],
[px(cx + r * 0.78), px(cy - r * 0.12)],
[px(cx), px(cy + r * 0.85)]]);
} else { // floors: stairs
for (var i = 0; i < 3; i++) {
dc.fillRectangle(px(cx - r * 0.85 + r * 0.58 * i),
px(cy + r * 0.6 - r * 0.55 * i),
px(r * 0.62), px(r * 0.4));
}
}
}
}
// ------------------------------------------------------------ elements
function drawBattery(dc as Dc, ti as Number, stats as System.Stats) as Void {
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; }
iconBattery(dc, BATT_X, BATT_CY, 1.0, Themes.ACCENT[ti] as Number, level);
text(dc, BATT_VX, BATT_CY, mFontBand, (level * 100.0).toNumber().format("%d") + "%",
Graphics.TEXT_JUSTIFY_LEFT, Themes.TEXTPRIMARY[ti] as Number);
}
function drawTopComplication(dc as Dc, ti as Number, typeIdx as Number, isLeft as Boolean,
info as ActivityMonitor.Info, weather as Weather.CurrentConditions?,
stats as System.Stats, ds as System.DeviceSettings) as Void {
var x = isLeft ? LEFT_X : RIGHT_X;
var level = 0.0f;
if (stats != null && stats.battery != null) { level = stats.battery / 100.0; }
drawComplicationIcon(dc, x, ICON_CY, 1.0, Themes.ACCENT[ti] as Number, typeIdx, level);
text(dc, x, VALUE_CY, mFontComp,
complicationValue(typeIdx, info, weather, stats, ds),
Graphics.TEXT_JUSTIFY_CENTER, Themes.TEXTPRIMARY[ti] as Number);
}
function drawBluetooth(dc as Dc, col as Number) as Void {
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(maxNum(2, px(3.2)));
var l = BT_CX - BT_HW;
var r = BT_CX + BT_HW;
var t = BT_CY - BT_HH;
var b = BT_CY + BT_HH;
var qt = BT_CY - BT_HH / 2;
var qb = BT_CY + BT_HH / 2;
stub(dc, l, qt, r, qb);
stub(dc, r, qb, BT_CX, b);
stub(dc, BT_CX, b, BT_CX, t);
stub(dc, BT_CX, t, r, qt);
stub(dc, r, qt, l, qb);
}
function drawDateBand(dc as Dc, ti as Number, ds as System.DeviceSettings) as Void {
var fill = Themes.BANDFILL[ti] as Number;
dc.setColor(fill, fill);
dc.fillRectangle(0, px(BAND_Y), mW, px(BAND_H));
var col = Themes.BANDTEXT[ti] as Number;
drawBluetooth(dc, (ds != null && !ds.phoneConnected)
? blend(col, fill, 0.6) : col);
// FORMAT_SHORT, not FORMAT_MEDIUM: only the short form returns
// day_of_week and month as numbers rather than localised strings.
var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
text(dc, DOW_CX, BAND_CY, mFontBand, mDow[g.day_of_week - 1],
Graphics.TEXT_JUSTIFY_CENTER, col);
// DateFormat is "$1$ $2$" in English and "$1$$2$日" in Chinese, where
// the Chinese month names already carry their own 月.
text(dc, MD_CX, BAND_CY, mFontBand,
Lang.format(mDateFmt, [mMon[g.month - 1], g.day.format("%d")]),
Graphics.TEXT_JUSTIFY_CENTER, col);
if (ds == null || !ds.is24Hour) {
text(dc, AMPM_R, BAND_CY, mFontBand, mMeridiem[g.hour >= 12 ? 1 : 0],
Graphics.TEXT_JUSTIFY_RIGHT, col);
}
}
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;
}
function drawTime(dc as Dc, ti as Number, ds as System.DeviceSettings) as Void {
var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
var h = g.hour;
if (ds == null || !ds.is24Hour) {
if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; }
}
gradText(dc, HOURS_R, TIME_CY, mFontTime, h.format("%d"),
Graphics.TEXT_JUSTIFY_RIGHT, Themes.HOURS[ti] as Array<Number>);
gradText(dc, MINUTES_L, TIME_CY, mFontTime, g.min.format("%02d"),
Graphics.TEXT_JUSTIFY_LEFT, Themes.MINUTES[ti] as Array<Number>);
dc.setColor(Themes.COLON[ti] as Number, Graphics.COLOR_TRANSPARENT);
dc.fillRectangle(px(COLON_X), px(COLON_Y1), px(COLON_W), px(COLON_H));
dc.fillRectangle(px(COLON_X), px(COLON_Y2), px(COLON_W), px(COLON_H));
}
function drawBottom(dc as Dc, ti as Number, info as ActivityMonitor.Info,
weather as Weather.CurrentConditions?, stats as System.Stats,
ds as System.DeviceSettings) as Void { ds as System.DeviceSettings) as Void {
var by = (286.5 * s).toNumber(); var value = Themes.TEXTPRIMARY[ti] as Number;
var bh = (44.0 * s).toNumber(); var label = Themes.ACCENT[ti] as Number;
dc.setColor(theme["bandFill"] as Number, theme["bandFill"] as Number); var left = getSetting("BottomLeft", 3);
dc.fillRectangle(0, by, w, bh); var right = getSetting("BottomRight", 2);
var txtCol = theme["bandText"] as Number; text(dc, DIST_R, BOT_VAL_CY, mFontComp,
var yc = (308.5 * s).toNumber(); complicationValue(left, info, weather, stats, ds),
drawBluetooth(dc, (36.5 * s).toNumber(), yc, (10.0 * s).toNumber(), txtCol); Graphics.TEXT_JUSTIFY_RIGHT, value);
var gi = Gregorian.info(Time.now(), Time.FORMAT_MEDIUM); text(dc, DIST_R, BOT_LAB_CY, mFontComp, fieldLabel(left),
var DOW = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]; Graphics.TEXT_JUSTIFY_RIGHT, label);
var MON = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; text(dc, STEP_L, BOT_VAL_CY, mFontComp,
var dow = DOW[gi.day_of_week - 1]; complicationValue(right, info, weather, stats, ds),
var md = MON[gi.month - 1] + " " + gi.day.format("%d"); Graphics.TEXT_JUSTIFY_LEFT, value);
dc.setColor(txtCol, Graphics.COLOR_TRANSPARENT); text(dc, STEP_L, BOT_LAB_CY, mFontComp, fieldLabel(right),
dc.drawText((177.5 * s).toNumber(), yc, Graphics.FONT_SMALL, dow, Graphics.TEXT_JUSTIFY_LEFT, label);
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 // ----------------------------------------------------------------- main
function onUpdate(dc as Dc) as Void { function onUpdate(dc as Dc) as Void {
var w = dc.getWidth(); if (mW != dc.getWidth() || mDow == null) { onLayout(dc); }
var h = dc.getHeight(); if (dc has :setAntiAlias) { dc.setAntiAlias(true); }
var s = w / 500.0;
var cx = w / 2;
var cy = h / 2;
var theme = currentTheme(); var ti = themeIndex();
var info = ActivityMonitor.getInfo(); var info = ActivityMonitor.getInfo();
var stats = System.getSystemStats(); var stats = System.getSystemStats();
var ds = System.getDeviceSettings(); var ds = System.getDeviceSettings();
@@ -362,26 +686,26 @@ class Fenix8V3View extends WatchUi.WatchFace {
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); drawRing(dc, ti,
var pTL = metricProgress(getSetting("RingTL", 1), info); metricProgress(getSetting("RingTR", 0), info),
var pBR = metricProgress(getSetting("RingBR", 4), info); metricProgress(getSetting("RingTL", 1), info),
var pBL = metricProgress(getSetting("RingBL", 3), info); metricProgress(getSetting("RingBR", 4), info),
drawRing(dc, cx, cy, s, theme, pTR, pTL, pBR, pBL); metricProgress(getSetting("RingBL", 3), info));
if (getBool("ShowTop", true)) { if (getBool("ShowTop", true)) {
drawBattery(dc, cx, cy, s, theme, stats); drawBattery(dc, ti, stats);
drawTopComplication(dc, cx, cy, s, theme, getSetting("LeftTop", 1), true, info, weather, stats); drawTopComplication(dc, ti, getSetting("LeftTop", 6), true, info, weather, stats, ds);
drawTopComplication(dc, cx, cy, s, theme, getSetting("RightTop", 0), false, info, weather, stats); drawTopComplication(dc, ti, getSetting("RightTop", 8), false, info, weather, stats, ds);
} }
drawTime(dc, cx, cy, s, theme); drawTime(dc, ti, ds);
if (getBool("ShowBand", true)) { if (getBool("ShowBand", true)) {
drawDateBand(dc, w, cx, s, theme, ds); drawDateBand(dc, ti, ds);
} }
if (getBool("ShowBottom", true)) { if (getBool("ShowBottom", true)) {
drawBottom(dc, cx, s, theme, info); drawBottom(dc, ti, info, weather, stats, ds);
} }
} }
} }

View File

@@ -1,110 +1,109 @@
// AUTO-GENERATED from watchface-tokens.json — do not edit by hand. // AUTO-GENERATED by tools/gen_themes.py from design/watchface-*.svg.
module Themes { // Do not edit by hand -- change the design kit and re-run the generator.
// THEMES: id -> theme dictionary. //
// ringTop / ringBottom are gradient stop arrays: [angle, 0xRRGGBB] sorted ascending. // ringTop/ringBottom lit colour per tick, index 0 nearest 12 / 6 o'clock
var THEMES = { // hours/minutes 11-stop vertical gradient of the time digits, top -> bottom
"01" => { import Toybox.Lang;
"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,
}
};
// 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];
} }

329
tools/gen_preview_svg.py Normal file
View File

@@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""Render preview-themes.svg and preview.html by replaying the face's draw calls.
Colours are parsed straight out of source/Themes.mc and the geometry constants
are the same literals as source/Fenix8V3View.mc, so the preview cannot drift
from the firmware. Text is drawn with a web font rather than Garmin's Roboto
Condensed / Bionic, so judge geometry and colour here, not glyph shapes.
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()

199
tools/gen_themes.py Normal file
View File

@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Generate source/Themes.mc from the design kit in design/.
Ground truth is the seven watchface-NN-*.svg files (produced by design/rebuild.py
from the original raster comps). Every colour written to Themes.mc is measured
from those files -- nothing here is hand-picked.
python3 tools/gen_themes.py
Extracted per theme:
ringTop[21] lit colour of each top tick, index 0 = 6.5 deg .. 20 = 86.5 deg
ringBottom[21] lit colour of each bottom tick, index 0 = 6.0 deg .. 20 = 66.0 deg
tickOff colour of an unlit tick
anchorDot the four dots between the arcs
bandFill/Text date band background / foreground
colon the two colon blocks
hours[11] vertical gradient ramp of the hour digits, top -> bottom
minutes[11] ditto for the minute digits
accent icons and DST/STEP labels
textPrimary complication and bottom-row values
Only 14 of the 21 bottom ticks are lit in any comp, so indices 14..20 of
ringBottom are extrapolated (least-squares fit over the last 8 measured ticks).
Those ticks only appear once a metric passes ~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("// AUTO-GENERATED by tools/gen_themes.py from design/watchface-*.svg.")
L.append("// Do not edit by hand -- change the design kit and re-run the generator.")
L.append("//")
L.append("// ringTop/ringBottom lit colour per tick, index 0 nearest 12 / 6 o'clock")
L.append("// hours/minutes 11-stop vertical gradient of the time digits, top -> bottom")
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())