From 398018b51e81d819318ec411a906ce243a5be3d9 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Wed, 9 Sep 2026 22:04:27 +0800 Subject: [PATCH] Rebuild the face against the design kit; make fields configurable Vendor the watchface-kit into design/ and derive everything from it instead of from hand-transcribed constants. Geometry, from design/rebuild.py and the ink boxes of the traced SVG paths: - ring ticks are annular sectors (fillPolygon), not radial lines - top arc is 21 ticks at 6.5+4k, not 22 at 2+4k - exact radii, tick widths, anchor dots, band and colon rectangles - element positions taken from measured ink bounding boxes Colour, generated by tools/gen_themes.py straight out of the comps: - per-tick colour tables (21+21) replace 9/7 interpolated stops - 11-stop vertical gradients on the time digits, drawn with clip banding - per-element accent / value colours and per-theme unlit tick colour Data: the design's sunrise and hi/lo fields turn out to be available after all -- Weather.getSunrise and getDailyForecast both exist in SDK 9.1.0 -- so both are restored, and heart rate is added alongside them. Bugs found on the way: - Gregorian FORMAT_MEDIUM returns day_of_week/month as strings, so the date band crashed on every update (caught in the simulator) - Stats.battery is already 0-100, multiplying by 100 gave "4000%" - moveBarLevel is an integer level 0..5, not a 0..1 ratio, so the move bar arc was all-or-nothing Also: all four field slots are configurable with labels that follow the selection, temperature unit is a setting, on-face wording moved into resources with a Simplified Chinese variant, and 24h/distance/temperature now follow the watch. Fonts are chosen by measured cap height rather than by ascent. Verified in the Connect IQ simulator on fenix847mm and by compiling all 17 target devices. Co-Authored-By: Claude Opus 5 --- README.md | 314 +++++++---- design/rebuild.py | 329 ++++++++++++ design/watchface-01-ember.svg | 129 +++++ design/watchface-02-aurora.svg | 129 +++++ design/watchface-03-brass.svg | 129 +++++ design/watchface-04-voltage.svg | 129 +++++ design/watchface-05-reef.svg | 129 +++++ design/watchface-06-acid.svg | 129 +++++ design/watchface-07-kelp.svg | 129 +++++ design/watchface-tokens.json | 499 +++++++++++++++++ gen_preview_svg.py | 172 ------ monkey.jungle | 8 +- preview-themes.svg | 42 +- preview.html | 350 ++---------- resources-chn/strings/strings.xml | 53 ++ resources/settings/properties.xml | 7 +- resources/settings/settings.xml | 46 ++ resources/strings/strings.xml | 20 +- source/Fenix8V3View.mc | 862 ++++++++++++++++++++---------- source/Themes.mc | 213 ++++---- tools/gen_preview_svg.py | 329 ++++++++++++ tools/gen_themes.py | 199 +++++++ 22 files changed, 3334 insertions(+), 1012 deletions(-) create mode 100644 design/rebuild.py create mode 100644 design/watchface-01-ember.svg create mode 100644 design/watchface-02-aurora.svg create mode 100644 design/watchface-03-brass.svg create mode 100644 design/watchface-04-voltage.svg create mode 100644 design/watchface-05-reef.svg create mode 100644 design/watchface-06-acid.svg create mode 100644 design/watchface-07-kelp.svg create mode 100644 design/watchface-tokens.json delete mode 100644 gen_preview_svg.py create mode 100644 resources-chn/strings/strings.xml create mode 100644 tools/gen_preview_svg.py create mode 100644 tools/gen_themes.py 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())