diff --git a/README.md b/README.md index 21e5977..caa4323 100644 --- a/README.md +++ b/README.md @@ -1,168 +1,246 @@ # Fenix 8 V3 — Garmin 数据表盘 -一款基于 Connect IQ 设计稿(`watchface-kit`)1:1 还原的多指标数据表盘。设计画布为 **500×500** 正方形,渲染时按屏幕宽度等比缩放,支持圆形 AMOLED(Fenix 8 / Enduro 3 / Epix 2)与圆屏 MIP(Fenix 7 / FR965 等)设备。 +按 `watchface-kit` 设计稿 1:1 还原的多指标数据表盘。设计画布 **500×500**,渲染时按屏宽等比缩放 `s = screenWidth / 500`,支持圆形 AMOLED(Fenix 8 / Epix 2 / Venu 3 等)与圆屏 MIP(Fenix 7 / Enduro 3 / FR255 等)共 17 款设备。 -- **SDK**:Connect IQ SDK 9.1.0(`monkeyc` 9.1.0,API level `minSdkVersion="4.2.0"`) -- **语言**:Monkey C +- **SDK**:Connect IQ SDK 9.1.0(`monkeyc` 9.1.0,`minSdkVersion="4.2.0"`) +- **语言**:Monkey C;界面文案含英文与简体中文 - **类型**:`watchface` - **应用 ID**:`f8a3c1b2c3d44e5f8a9b0c1d2e3f4a5b` ---- - -## 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`。 +设计稿本体(7 张 SVG + tokens + 逆向脚本)在 `design/`,是本仓库所有坐标与配色的唯一来源。 --- -## 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) +``` -| # | 主题 | 主色调 | 分钟数字 | 日期带 | -|---|------|--------|----------|--------| -| 1 | Ember | 橙红渐变(黄→红) | `0xFCA901` | 橙底黑字 | -| 2 | Aurora | 青绿渐变(黄绿→青) | `0xAAF701` | 深灰底白字 | -| 3 | Brass | 金铜渐变 | `0xECCC93` | 深棕底白字 | -| 4 | Voltage | 蓝→黄渐变 | `0x52A8FD` | 蓝底黑字 | -| 5 | Reef | 橙→青渐变 | `0xFC7801` | 青底黑字 | -| 6 | Acid | 黄绿(近单色) | `0xC4F900` | 深灰底黑字 | -| 7 | Kelp | 橙→绿渐变 | `0xF4880A` | 深灰底黑字 | +三份实现(表盘 / SVG 预览 / HTML 预览)只有 **一份颜色数据**:`Themes.mc`。预览脚本直接解析它,不会与固件漂移。 --- -## 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` 的第 15–21 项无法实测,由 `gen_themes.py` 对最后 8 根做最小二乘线性外推得到。只有当指标超过约 67% 时才会显示这几根。 + +### 指标进度换算 + +| idx | 指标 | 分母 | +|-----|------|------| +| 0 | 步数 | `stepGoal` | +| 1 | 卡路里 | 固定 2000 kcal | +| 2 | 距离 | 10 km | +| 3 | 楼层 | `floorsClimbedGoal`(缺省 10) | +| 4 | 动动条 | `MOVE_BAR_LEVEL_MAX`(=5) | + +--- + +## 4. 时间数字的竖向渐变 + +设计稿的小时 / 分钟数字带竖向多段渐变(如 Ember 分钟由 `#FEAA00` 渐变到 `#FF2600`)。Monkey C 无法给文字直接填渐变,实现方式是**同一串数字画 11 遍,每遍用 `dc.setClip` 限制在一条横向色带内**,色带颜色取自 `Themes.HOURS` / `Themes.MINUTES`(各 11 个停止点,实测自设计稿的 `linearGradient`)。首尾两条色带向外延伸,保证字形不会有未绘制的缝隙。 + +--- + +## 5. 字号选择 + +Connect IQ 只暴露 `getFontAscent` / `getFontDescent` / `getFontHeight`,**没有字面高度接口**,而 ascent 里含有数字和大写字母用不到的变音符空间。因此代码用两个实测比例把 ascent 折算成 cap height: + +``` +文本字体(Roboto 系) cap ≈ 0.76 × ascent +数字字体(Bionic 系) cap ≈ 0.71 × ascent +``` + +比例是在模拟器里渲染后、从截图上量出墨迹得到的(fenix847mm:`FONT_XTINY` ascent 29 → 大写高 22px;`FONT_NUMBER_MILD` ascent 82 → 数字高 58px)。`pickFont` 取折算后**最接近**目标 cap height 的字体,而不是"最大且不超过"——后者会在设计尺寸落在两档字体之间时白白小一号。 + +各机型内置字体不完全相同(Roboto-Regular / RobotoCondensed-Bold / Yantramanav / 各机型位图字体),比例会有几个百分点误差。设计稿使用的是更粗更方的第三方字体,不随包分发,字形差异无法消除。 + +--- + +## 6. 七套主题 + +由 `tools/gen_themes.py` 从 `design/watchface-0N-*.svg` 生成。每套包含:顶/底弧各 21 根刻度色、未点亮刻度色、锚点色、日期带底/字色、冒号色、小时/分钟各 11 段渐变、强调色(图标与 DST/STEP 标签)、主文字色(各项数值)。 + +| # | 主题 | 主色调 | 强调色 | 日期带 | +|---|------|--------|--------|--------| +| 1 | Ember | 黄 → 红 | `#FEAA05` | 橙底黑字 | +| 2 | Aurora | 黄绿 → 青 | `#ADABAE` | 深灰底白字 | +| 3 | Brass | 金铜 | `#B57E4B` | 深棕底白字 | +| 4 | Voltage | 蓝 → 黄 | `#ADABAE` | 蓝底黑字 | +| 5 | Reef | 橙 → 青 | `#FEAA05` | 青底黑字 | +| 6 | Acid | 黄绿(近单色) | `#C6FA03` | 深灰底黑字 | +| 7 | Kelp | 橙 → 绿 | `#34C294` | 深灰底黑字 | + +改配色:编辑 / 替换 `design/` 里的 SVG,然后 + +```bash +python3 tools/gen_themes.py && python3 tools/gen_preview_svg.py +``` + +新增主题需同步在 `settings.xml` 的 `Theme` 列表与 `strings.xml`(含 `resources-chn/`)中登记。 + +--- + +## 7. 可配置设置 + +共 13 项,通过 Garmin Connect 手机 App 的「表盘设置」修改(定义在 `resources/settings/settings.xml`,默认值在 `properties.xml`)。 | 设置 | 键 | 可选值 | 默认 | |------|----|--------|------| -| 主题 | `Theme` | 1–7(Ember…Kelp) | 1 | -| 顶右弧指标 | `RingTR` | 0 步数 / 1 卡路里 / 2 距离 / 3 楼层 / 4 动动条 | 0 | -| 顶左弧指标 | `RingTL` | 同上 | 1 | -| 底右弧指标 | `RingBR` | 同上 | 4 | -| 底左弧指标 | `RingBL` | 同上 | 3 | -| 左上复杂功能 | `LeftTop` | 0 天气 / 1 卡路里 / 2 步数 / 3 距离 / 4 楼层 / 5 电量 | 1 | -| 右上复杂功能 | `RightTop` | 同上 | 0 | -| 显示顶行 | `ShowTop` | 布尔 | true | -| 显示日期带 | `ShowBand` | 布尔 | true | -| 显示底行 | `ShowBottom` | 布尔 | true | +| 主题 | `Theme` | 1–7 | 1 | +| 顶右 / 顶左 / 底右 / 底左弧指标 | `RingTR/TL/BR/BL` | 0 步数 / 1 卡路里 / 2 距离 / 3 楼层 / 4 动动条 | 0 / 1 / 4 / 3 | +| 左上数据 | `LeftTop` | 见下 | 6(日出) | +| 右上数据 | `RightTop` | 见下 | 8(最高/最低气温) | +| 左下数据 | `BottomLeft` | 见下 | 3(距离) | +| 右下数据 | `BottomRight` | 见下 | 2(步数) | +| 温度单位 | `TempUnit` | 0 跟随手表 / 1 °C / 2 °F | 0 | +| 显示顶行 / 日期带 / 底行 | `ShowTop` / `ShowBand` / `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 km(1,000,000 cm) | -| 3 | 楼层 Floors | `floorsClimbedGoal`(默认 10) | -| 4 | 动动条 Move Bar | `moveBarLevel`(归一化 0–1) | - -进度超出 1 会被钳制,弧长即“点亮”刻度数。 +底部两格的小标签跟随所选数据自动变化(`距离` / `步数` / `心率` …),不需要单独配置。 --- -## 4. 数据源与已知偏差 +## 8. 数据来源 -| 元素 | 数据来源 | 备注 | -|------|----------|------| -| 步数 / 卡路里 / 距离 / 楼层 / 动动条 | `ActivityMonitor.getInfo()` | SDK 9.1.0 字段:`steps/stepGoal/calories/distance/floorsClimbed/floorsClimbedGoal/moveBarLevel` | -| 天气温度 | `Weather.getCurrentConditions().temperature` | 需手表已配对天气 | -| 电量 | `System.getSystemStats().battery` | 0–1 浮点,×100 显示 | -| 日期 / 时间 | `Time.Gregorian` | 12 小时制,带 AM/PM | -| 蓝牙图标 | `System.getDeviceSettings().phoneConnected`(仅取状态) | 见下方偏差 | +| 元素 | API | +|------|-----| +| 步数 / 卡路里 / 距离 / 楼层 / 动动条 | `ActivityMonitor.getInfo()` | +| 日出 / 日落 | `Weather.getSunrise/getSunset(location, Time.now())`,位置取 `CurrentConditions.observationLocationPosition`,回退 `Activity.getActivityInfo().currentLocation` | +| 最高 / 最低气温 | `Weather.getDailyForecast()[0].highTemperature / lowTemperature` | +| 当前气温 | `Weather.getCurrentConditions().temperature` | +| 电量 | `System.getSystemStats().battery`(0–100 的百分比) | +| 时间 / 日期 | `Time.Gregorian`,`FORMAT_SHORT`(只有短格式的 `day_of_week` / `month` 是数字) | +| 蓝牙 | `DeviceSettings.phoneConnected`,未连接时图标压暗 | -### ⚠️ 与设计稿的关键偏差 - -1. **“日出(sunrise)”元素 → 实时天气温度。** 设计稿顶部复杂功能中有一个日出图标及时间,但 `Weather.CurrentConditions` 在 SDK 9.1.0 **不暴露 sunrise 字段**。因此该位置改为显示**实时天气温度**(`NN°`),图标保留太阳造型。 -2. **进度环为实时数据**,非设计稿中的静态填充演示值;四段弧指标可在设置中自由分配。 -3. **蓝牙图标**目前按日期带文字色常显(未根据 `phoneConnected` 切换亮/暗),如需“未连接变暗”可后续在 `drawDateBand` 中据 `ds.phoneConnected` 调色。 +单位与制式跟随手表设置:`is24Hour`(24 小时制下不显示 AM/PM)、`distanceUnits`(km / mi)、`temperatureUnits`(°C / °F)。天气与日出需手表已配对并同步过数据,否则显示 `--`。 --- -## 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 -# 单设备(Enduro 3 主产物) -monkeyc -o bin/Fenix8V3.prg -f monkey.jungle -y developer_key.der -d enduro3 +monkeyc -o bin/Fenix8V3.prg -f monkey.jungle -y developer_key.der -d fenix847mm +``` -# 导出调试资源(含 -settings.json,便于模拟器载入设置) -monkeyc -o bin/Fenix8V3.prg -f monkey.jungle -y developer_key.der -d enduro3 -x +全部 17 款设备批量编译(已验证通过): -# 多设备批量(已验证可编译) -for d in enduro3 fenix843mm fenix847mm fenix8pro47mm fenix8solar47mm \ - fenix8solar51mm epix2 epix2pro47mm epix2pro51mm fenix7 fenix7x \ - fenix7s fr965 fr265 fr255 venu3 vivoactive5; do +```bash +for d in enduro3 fenix843mm fenix847mm fenix8pro47mm fenix8solar47mm fenix8solar51mm \ + epix2 epix2pro47mm epix2pro51mm fenix7 fenix7x fenix7s fr965 fr265 fr255 \ + venu3 vivoactive5; do monkeyc -o "bin/Fenix8V3_${d}.prg" -f monkey.jungle -y developer_key.der -d "$d" \ && echo "[$d] OK" || echo "[$d] FAIL" 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` 载入并实时切换主题/指标验证。 -2. **真机侧载(Garmin Connect / Garmin Express)**: - - 用 Garmin Express 或 Connect 手机 App 的“侧载(sideload)”功能导入对应设备的 `bin/Fenix8V3_.prg`; - - 或把 `.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) +```bash +connectiq & # 启动模拟器 +monkeydo bin/Fenix8V3_fenix847mm.prg fenix847mm # 载入表盘 ``` ---- +非 release 编译会同时产出 `bin/*-settings.json`,可在模拟器 `View → Settings` 载入以实时切换主题与指标。注意模拟器会**持久化**已保存的设置,改 `properties.xml` 的默认值对已运行过的实例不生效,需先重置应用数据。 -## 8. 二次开发与扩展 - -- **改主题配色**:编辑 `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`” 形式,改一处即可全局缩放生效。 +真机侧载:用 Garmin Express 或 Connect 手机 App 导入对应机型的 `bin/Fenix8V3_.prg`,或把 `.prg` 拷入手表 `/GARMIN/Apps/`。 --- -## 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 表盘。 diff --git a/design/rebuild.py b/design/rebuild.py new file mode 100644 index 0000000..acce4a7 --- /dev/null +++ b/design/rebuild.py @@ -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'') + return (f'{"".join(stops)}') + + +def build(path, tid, tname): + arr = np.array(Image.open(os.path.join(SRC, path)).convert("RGB")).astype(int) + defs, parts, tok = [], [], {} + + parts.append('') + + # ---- 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'') + ring = [] + for g in ("top-right", "top-left", "bottom-left", "bottom-right"): + ring.append(f'\n ' + "\n ".join(groups[g]) + "\n ") + 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'') + ring.append('\n ' + "\n ".join(dots) + "\n ") + parts.append('\n ' + "\n ".join(ring) + "\n") + tok["anchorDot"] = dot_col + + # ---- date band + fill = arr[300, 110].astype(float) + band = [f''] + 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'') + parts.append('\n ' + "\n ".join(band) + "\n") + 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'')) + colon_col = hx(np.median(arr[188:200, 242:258].reshape(-1, 3), axis=0)) + tok["colon"] = colon_col + colon = '' + "".join( + f'' + for x, y, w, h in COLON) + "" + parts.append('\n ' + tg[0][1] + "\n " + colon + "\n " + tg[1][1] + "\n") + + # ---- 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'') + tok[name] = hx(e) + parts.append('\n ' + "\n ".join(comp) + "\n") + + svg = ('\n' + f'Watch face {tid} {tname}\n' + f'{"".join(defs)}\n' + "\n".join(parts) + "\n\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) diff --git a/design/watchface-01-ember.svg b/design/watchface-01-ember.svg new file mode 100644 index 0000000..3685aed --- /dev/null +++ b/design/watchface-01-ember.svg @@ -0,0 +1,129 @@ + +Watch face 01 Ember + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/watchface-02-aurora.svg b/design/watchface-02-aurora.svg new file mode 100644 index 0000000..c0576a8 --- /dev/null +++ b/design/watchface-02-aurora.svg @@ -0,0 +1,129 @@ + +Watch face 02 Aurora + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/watchface-03-brass.svg b/design/watchface-03-brass.svg new file mode 100644 index 0000000..f09dfed --- /dev/null +++ b/design/watchface-03-brass.svg @@ -0,0 +1,129 @@ + +Watch face 03 Brass + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/watchface-04-voltage.svg b/design/watchface-04-voltage.svg new file mode 100644 index 0000000..ed0c1fe --- /dev/null +++ b/design/watchface-04-voltage.svg @@ -0,0 +1,129 @@ + +Watch face 04 Voltage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/watchface-05-reef.svg b/design/watchface-05-reef.svg new file mode 100644 index 0000000..cc16773 --- /dev/null +++ b/design/watchface-05-reef.svg @@ -0,0 +1,129 @@ + +Watch face 05 Reef + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/watchface-06-acid.svg b/design/watchface-06-acid.svg new file mode 100644 index 0000000..864e270 --- /dev/null +++ b/design/watchface-06-acid.svg @@ -0,0 +1,129 @@ + +Watch face 06 Acid + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/watchface-07-kelp.svg b/design/watchface-07-kelp.svg new file mode 100644 index 0000000..c3ae95f --- /dev/null +++ b/design/watchface-07-kelp.svg @@ -0,0 +1,129 @@ + +Watch face 07 Kelp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/watchface-tokens.json b/design/watchface-tokens.json new file mode 100644 index 0000000..7ccb5ab --- /dev/null +++ b/design/watchface-tokens.json @@ -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" + } +} \ No newline at end of file diff --git a/gen_preview_svg.py b/gen_preview_svg.py deleted file mode 100644 index 3f549d1..0000000 --- a/gen_preview_svg.py +++ /dev/null @@ -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'' - -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') - 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'' - f'' - f'' - f'{round(pct)}%') - -def complic_icon(x,y,r,col,t): - c=hx(col) - if t==0: # sun - s=f'' - for i in range(8): - rad=math.radians(i*45) - s+=f'' - return s - if t==1: # flame - return f'' - if t==2: # steps - return (f'' - f'') - if t==3: # dot - return f'' - if t==4: # chevron - return f'' - return f'' - -def top_comp(theme,t,isLeft,val): - sx=156 if isLeft else 343; iy=90; vy=130.5 - return (f'{val}' - +complic_icon(sx,iy,13,theme["accent"],t)) - -def bluetooth(x,y,r,col): - c=hx(col) - return (f'') - -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'' - +bluetooth(36.5,yc,10,bt) - +f'{dow}' - +f'{md}' - +f'{"PM" if gi["h"]>=12 else "AM"}') - -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'{hs}' - f'{ms}' - f'' - f'') - -def bottom(theme,dist,steps): - vy=366;ly=410.5;lx=234;rx=266;tp=theme["textPrimary"];ac=theme["accent"] - return (f'{dist}' - f'DST' - f'{steps}' - f'STEP') - -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'') - 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''] -parts.append(f'') -for i,tid in enumerate(ORDER): - ox=gap+i*(cell+gap); oy=24 - sc=cell/500.0 - parts.append(f'') - parts.append(f''+face(tid,m,battery,temp,gi)+'') - parts.append(f'{i+1} · {THEMES[tid]["name"]}') -parts.append('') -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) diff --git a/monkey.jungle b/monkey.jungle index 9a92609..19faf56 100644 --- a/monkey.jungle +++ b/monkey.jungle @@ -1,9 +1,7 @@ project.manifest = manifest.xml 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 - -# Uncomment and adjust once you have SDK devices installed, e.g.: -# fenix8.resourcePath = resources;resources-round-amoled -# fenix7.resourcePath = resources;resources-round-mip diff --git a/preview-themes.svg b/preview-themes.svg index 4162d53..eb4dd1d 100644 --- a/preview-themes.svg +++ b/preview-themes.svg @@ -1,24 +1,24 @@ - -78%1,28023°1008TUESEP 9AM6.5DST8,400STEP -1 · Ember - -78%1,28023°1008TUESEP 9AM6.5DST8,400STEP -2 · Aurora - -78%1,28023°1008TUESEP 9AM6.5DST8,400STEP -3 · Brass - -78%1,28023°1008TUESEP 9AM6.5DST8,400STEP -4 · Voltage - -78%1,28023°1008TUESEP 9AM6.5DST8,400STEP -5 · Reef - -78%1,28023°1008TUESEP 9AM6.5DST8,400STEP -6 · Acid - -78%1,28023°1008TUESEP 9AM6.5DST8,400STEP -7 · Kelp + +40%6:3463°/52°1122MONMAY 26AM0.7DST1337STEP +1 · Ember + +40%6:3463°/52°1122MONMAY 26AM0.7DST1337STEP +2 · Aurora + +40%6:3463°/52°1122MONMAY 26AM0.7DST1337STEP +3 · Brass + +40%6:3463°/52°1122MONMAY 26AM0.7DST1337STEP +4 · Voltage + +40%6:3463°/52°1122MONMAY 26AM0.7DST1337STEP +5 · Reef + +40%6:3463°/52°1122MONMAY 26AM0.7DST1337STEP +6 · Acid + +40%6:3463°/52°1122MONMAY 26AM0.7DST1337STEP +7 · Kelp \ No newline at end of file diff --git a/preview.html b/preview.html index fa98f51..d3f702c 100644 --- a/preview.html +++ b/preview.html @@ -1,322 +1,38 @@ - - - + -Fenix 8 V3 表盘运行预览 +Fenix 8 V3 - 表盘预览 - - -
-

Fenix 8 V3 · 运行效果预览

-

网页版 1:1 还原 source/Fenix8V3View.mconUpdate 绘制逻辑:同坐标、同主题色、同指标填充。下方可切换 7 套主题、调指标进度、套圆形遮罩。

- -
-
- -
-
-

主题 Theme

-
-
- -
-

指标进度(驱动进度环)

-
-
-
-
-
-
默认值对应设计稿:顶右=步数 · 顶左=卡路里 · 底右=动动条 · 底左=楼层
-
- -
-

显示 / 外观

-
-
-
-
-
-
-
- - + :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} +
+

Fenix 8 V3 表盘预览

+

tools/gen_preview_svg.pysource/Themes.mc +生成,几何常量与 source/Fenix8V3View.mc 一致。文字用网页字体渲染, +真机为 Garmin 内置 Roboto / Bionic 字体,字形会有差异。

+
40%6:3463°/52°1122MONMAY 26AM0.7DST1337STEP40%6:3463°/52°1122MONMAY 26AM0.7DST1337STEP40%6:3463°/52°1122MONMAY 26AM0.7DST1337STEP40%6:3463°/52°1122MONMAY 26AM0.7DST1337STEP40%6:3463°/52°1122MONMAY 26AM0.7DST1337STEP40%6:3463°/52°1122MONMAY 26AM0.7DST1337STEP40%6:3463°/52°1122MONMAY 26AM0.7DST1337STEP
+
+
七套主题 · 进度环状态取自设计稿:右上 21/21、左上 10/21、右下 9/21、左下 14/21
- - - + 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)}); + }}); + diff --git a/resources-chn/strings/strings.xml b/resources-chn/strings/strings.xml new file mode 100644 index 0000000..299ef15 --- /dev/null +++ b/resources-chn/strings/strings.xml @@ -0,0 +1,53 @@ + + Fenix 8 V3 + + 主题 + 进度环 · 右上 + 进度环 · 左上 + 进度环 · 右下 + 进度环 · 左下 + 左上数据 + 右上数据 + 左下数据 + 右下数据 + 显示顶部一行 + 显示日期带 + 显示底部一行 + + 1 · 余烬 + 2 · 极光 + 3 · 黄铜 + 4 · 电压 + 5 · 珊瑚礁 + 6 · 酸绿 + 7 · 海藻 + + 步数 + 卡路里 + 距离 + 楼层 + 动动条 + + 气温 + 最高 / 最低气温 + 日出 + 日落 + 卡路里 + 步数 + 距离 + 楼层 + 电量 + 心率 + + + 周日,周一,周二,周三,周四,周五,周六 + 1月,2月,3月,4月,5月,6月,7月,8月,9月,10月,11月,12月 + $1$$2$日 + 上午,下午 + 气温,卡路里,步数,距离,楼层,电量,日出,日落,高低温,心率 + + 温度单位 + 跟随手表 + 摄氏度 (°C) + 华氏度 (°F) + diff --git a/resources/settings/properties.xml b/resources/settings/properties.xml index 5c321da..a85eaab 100644 --- a/resources/settings/properties.xml +++ b/resources/settings/properties.xml @@ -4,8 +4,11 @@ 1 4 3 - 1 - 0 + 6 + 8 + 3 + 2 + 0 true true true diff --git a/resources/settings/settings.xml b/resources/settings/settings.xml index eb89af8..6982840 100644 --- a/resources/settings/settings.xml +++ b/resources/settings/settings.xml @@ -54,22 +54,68 @@ @Strings.FieldWeather + @Strings.FieldHiLo + @Strings.FieldSunrise + @Strings.FieldSunset @Strings.FieldCalories @Strings.FieldSteps @Strings.FieldDistance @Strings.FieldFloors @Strings.FieldBattery + @Strings.FieldHeartRate @Strings.FieldWeather + @Strings.FieldHiLo + @Strings.FieldSunrise + @Strings.FieldSunset @Strings.FieldCalories @Strings.FieldSteps @Strings.FieldDistance @Strings.FieldFloors @Strings.FieldBattery + @Strings.FieldHeartRate + + + + + + @Strings.FieldWeather + @Strings.FieldHiLo + @Strings.FieldSunrise + @Strings.FieldSunset + @Strings.FieldCalories + @Strings.FieldSteps + @Strings.FieldDistance + @Strings.FieldFloors + @Strings.FieldBattery + @Strings.FieldHeartRate + + + + + + @Strings.FieldWeather + @Strings.FieldHiLo + @Strings.FieldSunrise + @Strings.FieldSunset + @Strings.FieldCalories + @Strings.FieldSteps + @Strings.FieldDistance + @Strings.FieldFloors + @Strings.FieldBattery + @Strings.FieldHeartRate + + + + + + @Strings.TempUnitAuto + @Strings.TempUnitC + @Strings.TempUnitF diff --git a/resources/strings/strings.xml b/resources/strings/strings.xml index 5397090..ae399b9 100644 --- a/resources/strings/strings.xml +++ b/resources/strings/strings.xml @@ -8,6 +8,8 @@ Ring · Bottom-Left Top-Left Complication Top-Right Complication + Bottom-Left Field + Bottom-Right Field Show Top Row Show Date Band Show Bottom Row @@ -26,10 +28,26 @@ Floors Move Bar - Weather + Temperature + Hi / Lo Temp + Sunrise + Sunset Calories Steps Distance Floors Battery + Heart Rate + + + SUN,MON,TUE,WED,THU,FRI,SAT + JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC + $1$ $2$ + AM,PM + TEMP,KCAL,STEP,DST,FLR,BATT,RISE,SET,HI/LO,HR + + Temperature Unit + Follow watch + Celsius (°C) + Fahrenheit (°F) diff --git a/source/Fenix8V3View.mc b/source/Fenix8V3View.mc index 54fbdd1..be4633d 100644 --- a/source/Fenix8V3View.mc +++ b/source/Fenix8V3View.mc @@ -3,6 +3,8 @@ import Toybox.Graphics; import Toybox.Lang; import Toybox.Math; import Toybox.ActivityMonitor; +import Toybox.Activity; +import Toybox.Position; import Toybox.Weather; import Toybox.System; import Toybox.Time; @@ -10,8 +12,163 @@ import Toybox.Time.Gregorian; import Toybox.WatchUi; 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 { + // ---------------------------------------------------------------- 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 { + var out = [] as Array; + 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, 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 function getSetting(name as String, def as Number) as Number { var v = Application.Properties.getValue(name); @@ -25,45 +182,58 @@ class Fenix8V3View extends WatchUi.WatchFace { return v as Boolean; } - function currentTheme() as Dictionary { - var idx = getSetting("Theme", 1); // 1..7 - var i = idx - 1; - if (i < 0) { i = 0; } - if (i > 6) { i = 0; } - var id = Themes.ORDER[i]; - return Themes.THEMES[id] as Dictionary; + function themeIndex() as Number { + var i = getSetting("Theme", 1) - 1; + if (i < 0 || i >= Themes.NAMES.size()) { i = 0; } + return i; } - function lerpColor(c1 as Number, c2 as Number, f as Float) as Number { - var r1 = (c1 & 0xFF0000) >> 16; - var g1 = (c1 & 0x00FF00) >> 8; - var b1 = (c1 & 0x0000FF); - var r2 = (c2 & 0xFF0000) >> 16; - var g2 = (c2 & 0x00FF00) >> 8; - var b2 = (c2 & 0x0000FF); - var r = (r1 + ((r2 - r1) * f).toNumber()).toNumber(); - var g = (g1 + ((g2 - g1) * f).toNumber()).toNumber(); - var b = (b1 + ((b2 - b1) * f).toNumber()).toNumber(); - return (r << 16) | (g << 8) | b; + // TEXT_JUSTIFY_VCENTER centres the font box. Caps occupy only the band + // from the baseline up by capHeight, so the ink sits high in that box -- + // push the draw down by the difference to land the ink on cy. + function inkY(font as Graphics.FontDefinition, dc as Dc, cy as Numeric, + ratio as Float) as Number { + var fh = dc.getFontHeight(font); + var asc = Graphics.getFontAscent(font); + return px(cy) + ((fh / 2.0) - asc + capHeight(font, ratio) / 2.0).toNumber(); } - // interpolate a gradient stop array [[angle,color],...] (angles ascending) - function lerpStops(stops as Array>, t as Number) as Number { - var n = stops.size(); - if (n == 0) { return 0x888888; } - var first = stops[0]; - 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); - } + function text(dc as Dc, x as Numeric, cy as Numeric, font as Graphics.FontDefinition, + str as String, just as Number, col as Number) as Void { + dc.setColor(col, Graphics.COLOR_TRANSPARENT); + dc.drawText(px(x), inkY(font, dc, cy, CAP_OF_TEXT), font, str, + just | Graphics.TEXT_JUSTIFY_VCENTER); + } + + // 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) 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 @@ -84,276 +254,430 @@ class Fenix8V3View extends WatchUi.WatchFace { ? info.floorsClimbedGoal.toFloat() : 10.0f; p = info.floorsClimbed.toFloat() / goal; } - } else if (idx == 4) { // Move Bar - if (info.moveBarLevel != null) { p = info.moveBarLevel.toFloat(); } + } else if (idx == 4) { // Move bar: an integer level, not a ratio + 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 < 0.0f) { p = 0.0f; } return p; } - // value string for a top complication type + // ------------------------------------------------------------ data bits + function distanceString(info as ActivityMonitor.Info, ds as System.DeviceSettings) as String { + if (info == null || info.distance == null) { return "0.0"; } + var km = info.distance.toFloat() / 100000.0; + if (ds != null && ds.distanceUnits == System.UNIT_STATUTE) { km = km * 0.621371; } + return km.format("%.1f"); + } + + // 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) as String { - if (typeIdx == 0) { // Weather temp - if (weather != null && weather.temperature != null) { - return weather.temperature.format("%d") + "\u00B0"; - } - return "--"; - } else if (typeIdx == 1) { // Calories + 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"; } else if (typeIdx == 2) { // Steps return info != null && info.steps != null ? info.steps.format("%d") : "0"; - } else if (typeIdx == 3) { // Distance km - if (info != null && info.distance != null) { - return (info.distance.toFloat() / 100000.0).format("%.1f"); - } - return "0.0"; + } else if (typeIdx == 3) { // Distance + return distanceString(info, ds); } else if (typeIdx == 4) { // Floors - return info != null && info.floorsClimbed != null ? info.floorsClimbed.format("%d") : "0"; - } else { // Battery + return info != null && info.floorsClimbed != null + ? info.floorsClimbed.format("%d") : "0"; + } else if (typeIdx == 5) { // Battery var pct = 0; if (stats != null && stats.battery != null) { pct = (stats.battery * 100.0).toNumber(); } 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 - function drawTick(dc as Dc, cx as Number, cy as Number, rIn as Number, rOut as Number, - w as Number, ang as Number, col as Number, mirror as Boolean) as Void { - var eff = mirror ? -ang : ang; - var rad = Math.toRadians(eff); - var sin = Math.sin(rad); - var cos = Math.cos(rad); - var ix = (cx + sin * rIn).toNumber(); - var iy = (cy - cos * rIn).toNumber(); - var ox = (cx + sin * rOut).toNumber(); - var oy = (cy - cos * rOut).toNumber(); - dc.setPenWidth(w); - dc.setColor(col, Graphics.COLOR_TRANSPARENT); - dc.drawLine(ix, iy, ox, oy); + function tick(dc as Dc, ang as Float, hw as Float) as Void { + var a0 = ang - hw; + var a1 = ang + hw; + dc.fillPolygon([[polarX(a0, R_OUT * mS), polarY(a0, R_OUT * mS)], + [polarX(a1, R_OUT * mS), polarY(a1, R_OUT * mS)], + [polarX(a1, R_IN * mS), polarY(a1, R_IN * mS)], + [polarX(a0, R_IN * mS), polarY(a0, R_IN * mS)]]); } - function drawArcTop(dc as Dc, cx as Number, cy as Number, rIn as Number, rOut as Number, - w as Number, angles as Array, stops as Array>, - off as Number, mirror as Boolean, lit as Number, anchorOuter as Boolean) as Void { - var n = angles.size(); + // Both top arcs fill from the 9/3 o'clock end towards 12; both bottom arcs + // fill outwards from 6 o'clock. That is what the comps show. + function arc(dc as Dc, ramp as Array, off as Number, lit as Number, + 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++) { - var a = angles[i]; - var on = anchorOuter ? (i >= n - lit) : (i < lit); - var col = on ? lerpStops(stops, a) : off; - drawTick(dc, cx, cy, rIn, rOut, w, a, col, mirror); + var a = phase + pitch * i; + var on = fromEnd ? (i >= n - lit) : (i < lit); + dc.setColor(on ? ramp[i] : off, Graphics.COLOR_TRANSPARENT); + tick(dc, base + (mirror ? -a : a), hw); } } - function drawArcBottom(dc as Dc, cx as Number, cy as Number, rIn as Number, rOut as Number, - w as Number, botAngles as Array, stops as Array>, - off as Number, mirror as Boolean, lit as Number) as Void { - var n = botAngles.size(); - for (var i = 0; i < n; i++) { - var a = botAngles[i]; - var on = (i < lit); - var ang = (180.0 - a).toNumber(); - var col = on ? lerpStops(stops, a) : off; - drawTick(dc, cx, cy, rIn, rOut, w, ang, col, mirror); + function drawRing(dc as Dc, ti as Number, pTR as Float, pTL as Float, + pBR as Float, pBL as Float) as Void { + var top = Themes.RINGTOP[ti] as Array; + var bot = Themes.RINGBOTTOM[ti] as Array; + var off = Themes.TICKOFF[ti] as Number; + + arc(dc, top, off, (pTR * TOP_N).toNumber(), TOP_PH, TOP_PITCH, TOP_HW, TOP_N, + 0.0, false, true); + arc(dc, top, off, (pTL * TOP_N).toNumber(), TOP_PH, TOP_PITCH, TOP_HW, TOP_N, + 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, - pTR as Float, pTL as Float, pBR as Float, pBL as Float) as Void { - var rOut = (249.5 * s).toNumber(); - var rIn = (231.5 * s).toNumber(); - var tickOff = theme["tickOff"] as Number; - var topStops = theme["ringTop"] as Array>; - var botStops = theme["ringBottom"] as Array>; - var topW = (13.0 * s).toNumber(); - var botW = (9.0 * s).toNumber(); - - var TOP = [2,6,10,14,18,22,26,30,34,38,42,46,50,54,58,62,66,70,74,78,82,86]; - var BOT = [6,9,12,15,18,21,24,27,30,33,36,39,42,45,48,51,54,57,60,63,66]; - - 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); + // ------------------------------------------------------------ icons + // The design ships three icons; they are rebuilt here from primitives at + // the sub-shape positions measured out of the SVG paths. + function iconBattery(dc as Dc, x as Numeric, cy as Numeric, scale as Float, + col as Number, level as Float) as Void { + var w = BATT_W * scale; + var h = BATT_H * scale; + var wall = BATT_WALL * scale; + var x0 = px(x); + var y0 = px(cy - h / 2); + var pw = px(w); + var ph = px(h); + var r = px(3.4 * scale); dc.setColor(col, Graphics.COLOR_TRANSPARENT); - if (typeIdx == 0) { // sun - dc.fillCircle(x, y, (r * 0.45).toNumber()); - for (var i = 0; i < 8; i++) { - var rad = Math.toRadians(i * 45.0); - var x1 = (x + Math.sin(rad) * r * 0.6).toNumber(); - var y1 = (y - Math.cos(rad) * r * 0.6).toNumber(); - var x2 = (x + Math.sin(rad) * r).toNumber(); - var y2 = (y - Math.cos(rad) * r).toNumber(); - dc.drawLine(x1, y1, x2, y2); + dc.fillRoundedRectangle(x0, y0, pw, ph, r); + dc.fillRectangle(x0 + pw, px(cy - BATT_TH * scale / 2), + px(BATT_TW * scale), px(BATT_TH * scale)); + var ix = x0 + px(wall); + var iy = y0 + px(wall); + var iw = pw - 2 * px(wall); + var ih = ph - 2 * px(wall); + dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT); + dc.fillRectangle(ix, iy, iw, ih); + var fill = (iw * level).toNumber(); + if (fill > 0) { + dc.setColor(col, Graphics.COLOR_TRANSPARENT); + dc.fillRectangle(ix, iy, fill, ih); + } + } + + // 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)); + } } - } else if (typeIdx == 1) { // flame - dc.fillPolygon([[x, (y - r).toNumber()], - [(x + r * 0.8).toNumber(), (y + r * 0.6).toNumber()], - [(x - r * 0.8).toNumber(), (y + r * 0.6).toNumber()]]); - } else if (typeIdx == 2) { // steps - dc.drawLine((x - r * 0.5).toNumber(), (y - r * 0.3).toNumber(), - (x + r * 0.5).toNumber(), (y - r * 0.3).toNumber()); - dc.drawLine((x - r * 0.3).toNumber(), y, (x + r * 0.6).toNumber(), y); - } else if (typeIdx == 3) { // distance dot - dc.fillCircle(x, y, (r * 0.5).toNumber()); - } else if (typeIdx == 4) { // floors chevron - dc.fillPolygon([[x, (y - r).toNumber()], - [(x + r * 0.7).toNumber(), (y + r * 0.2).toNumber()], - [(x - r * 0.7).toNumber(), (y + r * 0.2).toNumber()]]); - } else { // battery - dc.drawRectangle((x - r * 0.6).toNumber(), (y - r * 0.4).toNumber(), - (r * 1.2).toNumber(), (r * 0.8).toNumber()); } } - function drawTopComplication(dc as Dc, cx as Number, cy as Number, s as Float, theme as Dictionary, - typeIdx as Number, isLeft as Boolean, info as ActivityMonitor.Info, - weather as Weather.CurrentConditions, stats as System.Stats) as Void { - var sideX = isLeft ? (156.0 * s).toNumber() : (343.0 * s).toNumber(); - var iconY = (90.0 * s).toNumber(); - var valY = (130.5 * s).toNumber(); - var valStr = complicationValue(typeIdx, info, weather, stats); - dc.setColor(theme["textPrimary"] as Number, Graphics.COLOR_TRANSPARENT); - dc.drawText(sideX, valY, Graphics.FONT_MEDIUM, valStr, - Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER); - drawComplicationIcon(dc, sideX, iconY, (13.0 * s).toNumber(), theme["accent"] as Number, typeIdx); + // ------------------------------------------------------------ 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 drawBluetooth(dc as Dc, cx as Number, cy as Number, r as Number, col as Number) as Void { - dc.setPenWidth(3); + 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.drawLine(cx, (cy - r).toNumber(), cx, (cy + r).toNumber()); - dc.drawLine(cx, (cy - r).toNumber(), (cx + r * 0.7).toNumber(), (cy - r * 0.3).toNumber()); - dc.drawLine((cx + r * 0.7).toNumber(), (cy - r * 0.3).toNumber(), cx, cy); - dc.drawLine(cx, cy, (cx + r * 0.7).toNumber(), (cy + r * 0.3).toNumber()); - dc.drawLine((cx + r * 0.7).toNumber(), (cy + r * 0.3).toNumber(), cx, (cy + r).toNumber()); + 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, w as Number, cx as Number, s as Float, theme as Dictionary, - ds as System.DeviceSettings) as Void { - var by = (286.5 * s).toNumber(); - var bh = (44.0 * s).toNumber(); - dc.setColor(theme["bandFill"] as Number, theme["bandFill"] as Number); - dc.fillRectangle(0, by, w, bh); - var txtCol = theme["bandText"] as Number; - var yc = (308.5 * s).toNumber(); - drawBluetooth(dc, (36.5 * s).toNumber(), yc, (10.0 * s).toNumber(), txtCol); - var gi = Gregorian.info(Time.now(), Time.FORMAT_MEDIUM); - var DOW = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]; - var MON = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; - var dow = DOW[gi.day_of_week - 1]; - var md = MON[gi.month - 1] + " " + gi.day.format("%d"); - dc.setColor(txtCol, Graphics.COLOR_TRANSPARENT); - dc.drawText((177.5 * s).toNumber(), yc, Graphics.FONT_SMALL, dow, - Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER); - dc.drawText((297.5 * s).toNumber(), yc, Graphics.FONT_SMALL, md, - Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER); - var ampm = gi.hour >= 12 ? "PM" : "AM"; - dc.drawText((465.0 * s).toNumber(), yc, Graphics.FONT_SMALL, ampm, - Graphics.TEXT_JUSTIFY_RIGHT | Graphics.TEXT_JUSTIFY_VCENTER); + function 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 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 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 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); + 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); + gradText(dc, MINUTES_L, TIME_CY, mFontTime, g.min.format("%02d"), + Graphics.TEXT_JUSTIFY_LEFT, Themes.MINUTES[ti] as Array); + 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 { + var value = Themes.TEXTPRIMARY[ti] as Number; + var label = Themes.ACCENT[ti] as Number; + var left = getSetting("BottomLeft", 3); + var right = getSetting("BottomRight", 2); + text(dc, DIST_R, BOT_VAL_CY, mFontComp, + complicationValue(left, info, weather, stats, ds), + Graphics.TEXT_JUSTIFY_RIGHT, value); + text(dc, DIST_R, BOT_LAB_CY, mFontComp, fieldLabel(left), + Graphics.TEXT_JUSTIFY_RIGHT, label); + text(dc, STEP_L, BOT_VAL_CY, mFontComp, + complicationValue(right, info, weather, stats, ds), + Graphics.TEXT_JUSTIFY_LEFT, value); + text(dc, STEP_L, BOT_LAB_CY, mFontComp, fieldLabel(right), + Graphics.TEXT_JUSTIFY_LEFT, label); } // ----------------------------------------------------------------- main function onUpdate(dc as Dc) as Void { - var w = dc.getWidth(); - var h = dc.getHeight(); - var s = w / 500.0; - var cx = w / 2; - var cy = h / 2; + if (mW != dc.getWidth() || mDow == null) { onLayout(dc); } + if (dc has :setAntiAlias) { dc.setAntiAlias(true); } - var theme = currentTheme(); + var ti = themeIndex(); var info = ActivityMonitor.getInfo(); var stats = System.getSystemStats(); var ds = System.getDeviceSettings(); @@ -362,26 +686,26 @@ class Fenix8V3View extends WatchUi.WatchFace { dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK); dc.clear(); - var pTR = metricProgress(getSetting("RingTR", 0), info); - var pTL = metricProgress(getSetting("RingTL", 1), info); - var pBR = metricProgress(getSetting("RingBR", 4), info); - var pBL = metricProgress(getSetting("RingBL", 3), info); - drawRing(dc, cx, cy, s, theme, pTR, pTL, pBR, pBL); + drawRing(dc, ti, + metricProgress(getSetting("RingTR", 0), info), + metricProgress(getSetting("RingTL", 1), info), + metricProgress(getSetting("RingBR", 4), info), + metricProgress(getSetting("RingBL", 3), info)); if (getBool("ShowTop", true)) { - drawBattery(dc, cx, cy, s, theme, stats); - drawTopComplication(dc, cx, cy, s, theme, getSetting("LeftTop", 1), true, info, weather, stats); - drawTopComplication(dc, cx, cy, s, theme, getSetting("RightTop", 0), false, info, weather, stats); + drawBattery(dc, ti, stats); + drawTopComplication(dc, ti, getSetting("LeftTop", 6), true, info, weather, stats, ds); + 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)) { - drawDateBand(dc, w, cx, s, theme, ds); + drawDateBand(dc, ti, ds); } if (getBool("ShowBottom", true)) { - drawBottom(dc, cx, s, theme, info); + drawBottom(dc, ti, info, weather, stats, ds); } } } diff --git a/source/Themes.mc b/source/Themes.mc index e69b3e0..7787e31 100644 --- a/source/Themes.mc +++ b/source/Themes.mc @@ -1,110 +1,109 @@ -// AUTO-GENERATED from watchface-tokens.json — do not edit by hand. -module Themes { - // THEMES: id -> theme dictionary. - // ringTop / ringBottom are gradient stop arrays: [angle, 0xRRGGBB] sorted ascending. - var 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, - } - }; +// AUTO-GENERATED by tools/gen_themes.py from design/watchface-*.svg. +// Do not edit by hand -- change the design kit and re-run the generator. +// +// ringTop/ringBottom lit colour per tick, index 0 nearest 12 / 6 o'clock +// hours/minutes 11-stop vertical gradient of the time digits, top -> bottom +import Toybox.Lang; - // theme id list for settings enum (1-based index -> id) - var ORDER = [ - "01", "02", "03", "04", "05", "06", "07" +module Themes { + + 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]; } diff --git a/tools/gen_preview_svg.py b/tools/gen_preview_svg.py new file mode 100644 index 0000000..ed0572c --- /dev/null +++ b/tools/gen_preview_svg.py @@ -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('' % ( + " ".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('' + % (x, y, DOT_R, hx(T["ANCHORDOT"][th]))) + return "".join(out) + + +def txt(x, cy, cap, s, anchor, col, weight=700): + return ('%s' + % (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('' % ((i + 0.5) / n, hx(c)) + for i, c in enumerate(ramp)) + d = ('%s' % (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 = ['' + % (x, y, w, h, 3.4 * k, col), + '' + % (x + w, cy - BATT_TH * k / 2, BATT_TW * k, BATT_TH * k, col), + '' + % (x + wall, y + wall, w - 2 * wall, h - 2 * wall)] + if level > 0: + o.append('' + % (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 = ['' + % (" ".join("%.2f,%.2f" % q for q in pts), col)] + + def ln(x1, y1, x2, y2, w): + return ('' + % (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 ('' + % (x1, y1, x2, y2, col, w)) + + def body(grow, fill): + return ('' + '' + '' + % (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 = ['' + % (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 ('' + % (" ".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 = [], ['', + 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('' + % (COLON_X, y, COLON_W, COLON_H, hx(T["COLON"][th]))) + + band, btxt = hx(T["BANDFILL"][th]), hx(T["BANDTEXT"][th]) + s.append('' + % (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 = """ + + +Fenix 8 V3 - 表盘预览 +
+

Fenix 8 V3 表盘预览

+

tools/gen_preview_svg.pysource/Themes.mc +生成,几何常量与 source/Fenix8V3View.mc 一致。文字用网页字体渲染, +真机为 Garmin 内置 Roboto / Bionic 字体,字形会有差异。

+
%(faces)s
+
%(buttons)s
+
七套主题 · 进度环状态取自设计稿:右上 21/21、左上 10/21、右下 9/21、左下 14/21
+
+ +""" + + +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 = ['' % (W, H, W, H), + '' % (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('' + '%s' + % (i, ox + cell / 2, oy + cell / 2, cell / 2 - 2, defs)) + parts.append('%s' % (i, ox, oy, sc, body)) + parts.append('%d · %s' + % (ox + cell / 2, oy + cell + 22, i + 1, name)) + parts.append("") + 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('%s%s' + % (i, defs, body)) + btns = "".join('' + % (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() diff --git a/tools/gen_themes.py b/tools/gen_themes.py new file mode 100644 index 0000000..cfa6898 --- /dev/null +++ b/tools/gen_themes.py @@ -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'(.*?)' % 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']*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'(.*?)' % 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"Watch face \d+ (\w+)", 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'\s*]*fill="(#[0-9A-Fa-f]{6})"', svg).group(1) + dot = re.search(r'\s*]*fill="(#[0-9A-Fa-f]{6})"', svg).group(1) + colon = re.search(r']*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())