From 94e91882f2d8a22277a02dfe11582adb8cb21abb Mon Sep 17 00:00:00 2001 From: Evan <306026806+evanwong89@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:06:48 +0800 Subject: [PATCH] Improve map zoom gestures and quick-start docs --- README.md | 37 +++++++++++++++++++----------- index.md | 4 ++-- interaction.md | 2 +- src/i18n.ts | 2 ++ src/renderer/map-controller.ts | 16 ++++++------- src/renderer/map-options.ts | 11 +++++++++ src/renderer/wheel-zoom-gate.ts | 17 ++++++++++++++ styles.css | 4 +++- tests/unit/i18n.test.ts | 2 ++ tests/unit/map-options.test.ts | 19 +++++++++++++++ tests/unit/wheel-zoom-gate.test.ts | 23 +++++++++++++++++++ trd.md | 6 ++--- 12 files changed, 114 insertions(+), 29 deletions(-) create mode 100644 src/renderer/map-options.ts create mode 100644 src/renderer/wheel-zoom-gate.ts create mode 100644 tests/unit/map-options.test.ts create mode 100644 tests/unit/wheel-zoom-gate.test.ts diff --git a/README.md b/README.md index e3165df..ffdfd00 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,19 @@ Footprint Map renders a local, time-ordered travel diary from photo locations in 中文说明见 [简体中文](#简体中文)。 +## Quick start + +1. Install and enable the Footprint Map plugin. +2. Open the Obsidian note where you want to generate a Footprint Map. +3. Press `Command + P` to open the Command Palette, search for `Footprint`, and select **Footprint Map: Generate footprint from photos in the current note**. A footprint map will be generated in the original note, as shown in the preview. + +### Notes + +The following images may not appear in the Footprint Map: + +1. Images downloaded from the internet. +2. Photos taken without location information enabled. + ![Footprint Map showing time-ordered photo places, the selected place gallery, and the synchronized timeline.](assets/footprint-map-daily-footprint.png) *Visualize a day's photo journey on an interactive map, with time-ordered places, photo galleries, and a synchronized timeline.* @@ -46,15 +59,6 @@ After the plugin is accepted into the Obsidian community directory: Do not copy another person's `data.json`; it contains vault-specific preferences and secret references. -## Quick start - -1. Open a Markdown note that embeds local JPEG, HEIC, or PNG photos. -2. Run **Generate footprint from photos in the current note** from the command palette. -3. Review the generated `.footprint.geojson` file and the `footprint-map` code block appended to the note. -4. Run **Export a static footprint preview** if you also want an SVG fallback. - -Only valid photo metadata is imported. Missing GPS, missing timezone information, and damaged files are reported rather than guessed. - ## Markdown format ````markdown @@ -159,11 +163,16 @@ Footprint Map 从 Obsidian 笔记引用的本地照片中读取 GPS 与带时区 ### 快速使用 -1. 安装并启用插件。 -2. 打开引用了本地 JPEG、HEIC 或 PNG 照片的笔记。 -3. 在命令面板执行“从当前笔记的照片生成足迹”。 -4. 插件会在笔记旁生成 GeoJSON,并在笔记中加入 `footprint-map` 代码块。 -5. 如需静态降级图,再执行“导出当前笔记的静态足迹图”。 +1. 安装并启用 Footprint Map 插件。 +2. 打开 Obsidian 中想要生成 Footprint Map 的笔记。 +3. 按 `Command + P` 打开命令面板,搜索 `Footprint`,选择 **Footprint Map:从当前笔记的照片生成足迹**,原笔记中即可生成足迹(效果如预览图)。 + +### 注意事项 + +以下情况可能会导致图片无法展示在 Footprint Map 的结果中: + +1. 网络下载的图片。 +2. 拍摄时未启用位置信息的照片。 默认底图为 OpenStreetMap,无需 Key。中国大陆用户可以切换高德静态地图,并在 Obsidian 安全存储中选择或创建包含“Web 服务 API Key”的凭据;旧的 Web 端(JS API)Key 不能代替该 Key。高德请求只在视野稳定或拖动、缩放结束后防抖发起;点位、连线、照片和时间轴仍在本地渲染。高级用户也可以使用自定义瓦片,或设置 `tiles: false` 完全关闭在线底图。 diff --git a/index.md b/index.md index 177a3a3..cbf5621 100644 --- a/index.md +++ b/index.md @@ -4,12 +4,12 @@ ## 项目状态 -- 状态:MVP 已实现并通过类型检查、自动化测试与生产构建;待 Obsidian Desktop/Mobile 和真实照片样本验收。 +- 状态:MVP 已实现并通过类型检查、自动化测试、生产构建与本地 Browser 页面滚动验证;待 Obsidian Desktop/Mobile、物理触控板捏合手感和真实照片样本验收。 - 关联需求:[[../../Requirements/REQ-004-可移植Markdown互动足迹地图|REQ-004:可移植 Markdown 互动足迹地图]] - 当前范围:Obsidian 日记内嵌互动地图、Leaflet/OpenStreetMap 与高德静态地图底图、照片缩略图点位与地图外横向照片浏览、200 米连续照片聚合、Vault 内 JPEG/HEIC/PNG 的 TypeScript EXIF 解析、GeoJSON 通用数据、独立 HTML 查看器与静态降级图。 - 项目边界:独立编写与构建,不依赖工作区内其他本地项目。 - 非目标:操作系统后台任务、Apple 原生框架、原生配套 App 及 Python/Swift 外部运行时。 -- 实施基线:单日地图、Leaflet、外部 GeoJSON、可关闭的 OpenStreetMap 在线瓦片、缺失时区不猜测、HEIC 无法显示时占位。 +- 实施基线:单日地图、Leaflet、外部 GeoJSON、可关闭的 OpenStreetMap 在线瓦片、普通滚动优先浏览文档且修饰键/捏合手势缩放地图、缺失时区不猜测、HEIC 无法显示时占位。 ## 项目文档 diff --git a/interaction.md b/interaction.md index d9909cf..e803816 100644 --- a/interaction.md +++ b/interaction.md @@ -53,7 +53,7 @@ stateDiagram-v2 ## 地图内交互 - **拖动:** 改变当前视野,不改变足迹数据。 -- **缩放:** 通过地图 `+`/`−` 控件或移动端双指手势调整比例;桌面滚轮和触控板滚动默认留给 Markdown 页面,不缩放地图。 +- **缩放:** 通过地图 `+`/`−` 控件、移动端双指手势、Mac 触控板捏合,或按住 `Command`/`Ctrl` 后滚动调整比例;无修饰键双指滚动或鼠标滚轮继续滚动 Markdown 页面,不改变地图级别。 - **适配视野:** 将所有有效点位恢复到当前地图容器内。 - **到访点:** 以首图作为 1.5 cm 方形缩略图,序号徽标圆心位于卡片右上角并主要处于图片外侧,底栏只显示第一张照片时间;点击切换选中态。 - **照片:** 不在地图上打开弹层;地图下方独立区域横向排列所选地点的全部照片,单图高度不超过 4 cm,可左右滑动。 diff --git a/src/i18n.ts b/src/i18n.ts index 65f0509..d8f514e 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -25,6 +25,7 @@ const EN = { customTileMaxZoom: "Custom tile maximum zoom", customTileMaxZoomDesc: "Set this to the maximum zoom supported by the provider.", legend: "Dashed lines and arrows show time order only, not the actual route taken.", + zoomHint: "Pinch or hold ⌘/Ctrl while scrolling to zoom the map.", fitAll: "Fit all places", placeAria: "Place {sequence}, {time}", primaryPhotoAlt: "First photo from place {sequence}", @@ -89,6 +90,7 @@ const ZH: Record = { customTileMaxZoom: "自定义瓦片最大缩放级别", customTileMaxZoomDesc: "按服务商实际支持范围设置。", legend: "虚线和箭头仅表示时间顺序,不表示实际街道路线。", + zoomHint: "捏合,或按住 ⌘/Ctrl 滚动以缩放地图。", fitAll: "适配全部点位", placeAria: "地点 {sequence},{time}", primaryPhotoAlt: "地点 {sequence} 的第一张照片", diff --git a/src/renderer/map-controller.ts b/src/renderer/map-controller.ts index 1809617..7338bee 100644 --- a/src/renderer/map-controller.ts +++ b/src/renderer/map-controller.ts @@ -12,6 +12,8 @@ import type { I18n } from "../i18n"; import { element } from "./dom"; import type { MapController } from "./controller"; import { createAMapStaticMapImage } from "./amap-static-map"; +import { createLeafletMapOptions } from "./map-options"; +import { gateWheelZoom } from "./wheel-zoom-gate"; export type CoordinateTransform = ( longitude: number, @@ -167,6 +169,7 @@ export class FootprintMapController implements MapController { private readonly arrowMarkers: Array<{ marker: Marker; segment: DisplaySegment }> = []; private readonly updateArrows: () => void; private readonly cleanupInitialFit: () => void; + private readonly cleanupWheelZoomGate: () => void; private cleanupStaticMap: () => void = () => undefined; private tileLayer: TileLayer | undefined; private staticImageLayer: ImageOverlay | undefined; @@ -184,6 +187,7 @@ export class FootprintMapController implements MapController { heading.append( element("h3", "footprint-map-title", model.title), element("p", "footprint-map-legend", i18n.t("legend")), + element("p", "footprint-map-zoom-hint", i18n.t("zoomHint")), ); const fitButton = element("button", "footprint-map-fit", i18n.t("fitAll")); fitButton.type = "button"; @@ -196,14 +200,9 @@ export class FootprintMapController implements MapController { this.issuesRegion = this.createIssues(model); container.append(this.root); - this.map = L.map(mapElement, { - zoomControl: true, - attributionControl: true, - scrollWheelZoom: false, - zoomSnap: options.amapStaticMap ? 1 : 0.25, - minZoom: options.amapStaticMap ? 2 : undefined, - maxZoom: options.amapStaticMap ? 18 : undefined, - }); + mapElement.addEventListener("wheel", gateWheelZoom, { capture: true, passive: true }); + this.cleanupWheelZoomGate = () => mapElement.removeEventListener("wheel", gateWheelZoom, true); + this.map = L.map(mapElement, createLeafletMapOptions(Boolean(options.amapStaticMap))); if (options.tileProvider) { this.tileLayer = L.tileLayer(options.tileProvider.urlTemplate, { attribution: options.tileProvider.attribution, @@ -408,6 +407,7 @@ export class FootprintMapController implements MapController { destroy(): void { this.cleanupInitialFit(); + this.cleanupWheelZoomGate(); this.cleanupStaticMap(); this.tileLayer?.off("tileerror", this.handleTileError); this.map.off("zoomend moveend", this.updateArrows); diff --git a/src/renderer/map-options.ts b/src/renderer/map-options.ts new file mode 100644 index 0000000..52bb3f3 --- /dev/null +++ b/src/renderer/map-options.ts @@ -0,0 +1,11 @@ +import type { MapOptions } from "leaflet"; + +export const createLeafletMapOptions = (usesAMapStaticBasemap: boolean): MapOptions => ({ + zoomControl: true, + attributionControl: true, + scrollWheelZoom: true, + touchZoom: true, + zoomSnap: usesAMapStaticBasemap ? 1 : 0.25, + minZoom: usesAMapStaticBasemap ? 2 : undefined, + maxZoom: usesAMapStaticBasemap ? 18 : undefined, +}); diff --git a/src/renderer/wheel-zoom-gate.ts b/src/renderer/wheel-zoom-gate.ts new file mode 100644 index 0000000..4482fe7 --- /dev/null +++ b/src/renderer/wheel-zoom-gate.ts @@ -0,0 +1,17 @@ +export interface WheelZoomEvent { + readonly ctrlKey: boolean; + readonly metaKey: boolean; + stopImmediatePropagation(): void; +} + +export const isIntentionalWheelZoom = ( + event: Pick, +): boolean => event.ctrlKey || event.metaKey; + +export const gateWheelZoom = (event: WheelZoomEvent): void => { + if (isIntentionalWheelZoom(event)) return; + + // Do not prevent the browser's default action: the surrounding document must + // continue scrolling. Stop only JavaScript propagation before Leaflet sees it. + event.stopImmediatePropagation(); +}; diff --git a/styles.css b/styles.css index cf18254..c6af404 100644 --- a/styles.css +++ b/styles.css @@ -27,7 +27,9 @@ .footprint-map-title, .footprint-map-header p { margin: 0; } .footprint-map-title { font-size: 1.15rem; } -.footprint-map-header .footprint-map-legend { color: var(--fm-muted); font-size: .78rem; margin-top: 3px; } +.footprint-map-header .footprint-map-legend, +.footprint-map-header .footprint-map-zoom-hint { color: var(--fm-muted); font-size: .78rem; margin-top: 3px; } +.footprint-map-header .footprint-map-zoom-hint { font-size: .72rem; } .footprint-map-fit, .footprint-map-source-form button { diff --git a/tests/unit/i18n.test.ts b/tests/unit/i18n.test.ts index 7bac6ec..dcc301e 100644 --- a/tests/unit/i18n.test.ts +++ b/tests/unit/i18n.test.ts @@ -10,11 +10,13 @@ describe("internationalization", () => { it("follows a Chinese Obsidian locale in automatic mode", () => { expect(resolveLocale("auto", "zh-cn")).toBe("zh-CN"); expect(createI18n("auto", "zh-cn").t("fitAll")).toBe("适配全部点位"); + expect(createI18n("auto", "zh-cn").t("zoomHint")).toContain("⌘/Ctrl"); }); it("allows an explicit language to override the host locale", () => { const i18n = createI18n("en", "zh-cn"); expect(i18n.t("fitAll")).toBe("Fit all places"); + expect(i18n.t("zoomHint")).toContain("Pinch"); expect(i18n.period("afternoon")).toBe("Afternoon"); }); diff --git a/tests/unit/map-options.test.ts b/tests/unit/map-options.test.ts new file mode 100644 index 0000000..3d4e692 --- /dev/null +++ b/tests/unit/map-options.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { createLeafletMapOptions } from "../../src/renderer/map-options"; + +describe("Leaflet map options", () => { + it("keeps Leaflet wheel and touch handlers available behind the intent gate", () => { + const options = createLeafletMapOptions(false); + + expect(options.scrollWheelZoom).toBe(true); + expect(options.touchZoom).toBe(true); + }); + + it("keeps static AMap zoom levels on whole-number steps", () => { + const options = createLeafletMapOptions(true); + + expect(options.zoomSnap).toBe(1); + expect(options.minZoom).toBe(2); + expect(options.maxZoom).toBe(18); + }); +}); diff --git a/tests/unit/wheel-zoom-gate.test.ts b/tests/unit/wheel-zoom-gate.test.ts new file mode 100644 index 0000000..64633ec --- /dev/null +++ b/tests/unit/wheel-zoom-gate.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; +import { gateWheelZoom, isIntentionalWheelZoom } from "../../src/renderer/wheel-zoom-gate"; + +describe("wheel zoom intent gate", () => { + it("leaves plain wheel events to the surrounding document", () => { + const stopImmediatePropagation = vi.fn(); + + gateWheelZoom({ ctrlKey: false, metaKey: false, stopImmediatePropagation }); + + expect(stopImmediatePropagation).toHaveBeenCalledOnce(); + }); + + it("allows trackpad pinch and Ctrl/Command wheel events to reach Leaflet", () => { + expect(isIntentionalWheelZoom({ ctrlKey: true, metaKey: false })).toBe(true); + expect(isIntentionalWheelZoom({ ctrlKey: false, metaKey: true })).toBe(true); + + const stopImmediatePropagation = vi.fn(); + gateWheelZoom({ ctrlKey: true, metaKey: false, stopImmediatePropagation }); + gateWheelZoom({ ctrlKey: false, metaKey: true, stopImmediatePropagation }); + + expect(stopImmediatePropagation).not.toHaveBeenCalled(); + }); +}); diff --git a/trd.md b/trd.md index 2eaa1fb..75c6ddb 100644 --- a/trd.md +++ b/trd.md @@ -1,12 +1,12 @@ # TRD:Footprint Map -**职责:** 将 REQ-004 v0.22 转化为可实施的技术基线,定义技术选型、代码层级、核心接口、数据格式、渲染流程、错误模型、测试范围与发布产物。 +**职责:** 将 REQ-004 v0.23 转化为可实施的技术基线,定义技术选型、代码层级、核心接口、数据格式、渲染流程、错误模型、测试范围与发布产物。 ## 文档状态 - 状态:技术设计草案,待实施前审阅。 - TRD 版本:v0.1。 -- 需求基线:[[../../Requirements/REQ-004-可移植Markdown互动足迹地图|REQ-004 v0.22]]。 +- 需求基线:[[../../Requirements/REQ-004-可移植Markdown互动足迹地图|REQ-004 v0.23]]。 - 项目设计:[[design|设计图]]、[[interaction|交互图]]、[[visual|效果图]]。 - 实施状态:MVP 源码、自动化测试、Obsidian 包与 standalone 产物已创建;待真实宿主和照片样本验收。 @@ -54,7 +54,7 @@ - 静态图作为 Leaflet `ImageOverlay` 的底层 pane;照片 Marker、地图外横向照片浏览区、虚线 Polyline、方向箭头与视野适配复用同一 Leaflet 控制器。 - 静态图仅在初始布局完成以及 `moveend` / `zoomend` 后防抖刷新,使用 `scale=2` 与受限的图片尺寸,不预取、不批量缓存。 - 请求失败时移除无效底图并显示警告,本地点位、连线、箭头、时间轴和照片仍可使用。 -- 作为 Markdown 内嵌组件,Leaflet 设置 `scrollWheelZoom: false`,让桌面滚动事件继续服务于文档浏览,同时保留主动地图交互。 +- 作为 Markdown 内嵌组件,Leaflet 滚轮处理器前设事件门控:无修饰键滚动保留给文档,只允许触控板捏合或按住 `Command`/`Ctrl` 后滚动缩放地图;移动端触摸缩放不受影响。 ### ADR-003:`exifr` 作为 EXIF 解析实现